> Source file last changed: 2026-11-03. Dates inside the document record when each part was written, checked or submitted.

> **Reading this document.** This is CHANNEL's working engineering specification, published as it is kept. It records design history as it goes: dates, work-item numbers such as `J1559`, and rulings by the project's owner. Where a section says it is superseded or reserved, it describes nothing in the app. What the app does today is on the help pages.

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

> **`J1573`, 27 September 2026: an envelope with bytes after its item is refused.** Counted first
> (`Envelopes with trailing bytes` in both reports) and refused only after the count read zero on
> three devices over Bluetooth, the local network and the Internet. Attachment slices are refused
> too.
>
> **28 September 2026: link-layer packets and group rosters are refused the same way.** A packet
> frame whose item re-encodes to a different length than the frame, and a roster with bytes after
> it, are refused and counted (`link packets and rosters` in both reports). Refused after that
> count read zero on the owner's iPhone and Android on 2.872, counting since 27 September 04:48.
> Every decoder in §1.1's list now accepts only the canonical encoding and nothing after it.

### 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 65536; `J3748`)
  **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** because the AEAD already authenticates it: the nonce is an input to the
tag computation, so altering it makes the tag fail (§3.1 gives the full reason; the test
`altering_the_nonce_breaks_authentication` holds it).

**`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.

A receiver that wants to know whether a message was carried does not read `hopLimit` alone (a sender that
forbids carrying seals 0, and so does one carried once with a limit of 1): the sender also writes the limit
it sealed with inside the payload, `hopBudget` (§5.1f). The difference is a count of carriers, and a label only.

**`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 65536 (`J3748`: the code
has held 65,536 since `J600`, the last size a `u16` `payloadLength` can describe; BLE carries at most 49,152). 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 CryptoSuite v1's compositions; ML-KEM-768
(§4.9) is the one addition, in the v2 pairing and in the ratchet's per-session mix. 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, and on its own it has no forward secrecy.** §4.5a
layers the Double Ratchet inside it for every contact both of whose phones advertise it; the static key
remains the outer layer, which is what keeps the ratchet's headers away from relays. *(This paragraph
used to say a ratchet would replace the derivation wholesale in a later suite. `J1559` chose to layer it
instead, for the reason just given, and because every existing pairing keeps working without a new
handshake.)*

### 4.5a Forward secrecy — the Double Ratchet (`J1559`, CryptoSuite v1)

§4.5's `messageKey` is static for a pairing's generation: whoever later obtains it can read every
message ever sealed under it. This section adds, **inside** the envelope, an independently developed
Double Ratchet design based on Signal's published specification (*The Double Ratchet Algorithm*,
Perrin and Marlinspike, Revision 4, 2025-11-04; section numbers here are revision 4's), with
CHANNEL-specific session establishment, recovery and post-quantum extensions. It contains no Signal
code. Stealing a phone's state today does not open messages it has already read, and the sessions in
a ratchet state stolen once stop working for the thief after one round trip **against an attacker who
only reads**. A phone's state also holds the pairing key, though, so the same passive thief reads the
opener's first chain of every LATER session, until the opener's first DH step on it, for as long as
the pairing lasts ("What it does not cover, stated", below). Implementation:
`mesh-core-rs/src/ratchet.rs`, the only one. Where it departs from the specification, and why, is
tabulated in `designs/J3812-double-ratchet-deviation-table.md`.

> **Not against one who sends -- measured 27 September 2026 (audit round 2, `J1633`).** A new ratchet
> session is accepted from anybody holding the pairing's `messageKey`, because that is how a
> restored phone recovers. So a holder of the pairing key alone can START a session, and the
> receiver then seals its replies into that session: one forged message takes over one direction
> of the conversation until a new session replaces it. The sentence above held only for a passive
> thief. Closing it needs the session start authenticated by something a thief of the pairing key
> does not hold -- a decision put to the owner, and a staged wire change.
>
> **Being closed: signed starts (`J1633`, the owner's go-ahead 30 September 2026).** Header flag
> `0x02` (`FLAG_SIGNED`) says a 64-byte Ed25519 signature by the sender's IDENTITY key follows the
> header, before the nonce, over
>
> ```
> "channel/ratchet-start-v2\0" ‖ session id (16) ‖ sender's ratchet public in this header (32)
>                              ‖ sender identity (32) ‖ recipient identity (32)
>                              ‖ SHA-256(header 58 ‖ nonce 12 ‖ ciphertext and tag) (32)
> ```
>
> The last field is the message itself (`J1837`, outside review N4): version 1 of the statement
> stopped at the identities, which made the signature the same bytes for every message on the
> opener's first chain -- and that chain's keys derive from the pairing key, so a holder of it
> could seal a forgery there and copy a genuine signature onto it. Binding the sealed message makes
> a signature good for exactly one ciphertext. No build ever signed with version 1 (stage 2 has
> been off everywhere), so there is nothing to keep reading.
>
> The flag is in the header and the header is in the AEAD's associated data, so it can be neither
> stripped nor added. The signature itself is not in the associated data: it authenticates the
> ratchet key it names and the one sealed message it was made over, and changing it can only make
> the message fail.
>
> **Which messages are signed (stage 2).** A phone signs every message it seals on a sending chain
> that the pairing key alone keys, or whose ratchet key will make the other phone's FIRST step --
> because a holder of the pairing key can produce exactly those, as either phone:
>
> - **the initiator: its whole first sending chain**, which is exactly the messages that carry
>   `INITIAL`. Forging one is OPENING a session as the initiator;
> - **the responder: its whole first sending chain** -- the chain the step that created the session
>   gives it -- however long it lasts. Whichever of those messages reaches the initiator first makes
>   the initiator's first step, so every one of them must be signed, or a reordered burst strands.
>   Forging one is ANSWERING an opening as the responder with a key of the forger's own, after which
>   the initiator's next chain is one the forger can derive (the external review of stage 1).
>
> A phone stops signing on a session at its next DH step: the initiator at its first, the responder
> at its second. Every chain after that comes from a DH between two keys made fresh on the two phones,
> neither derivable from the pairing key, so a message on it cannot be forged by a thief of the
> pairing key at all, signed or not.
>
> **Authenticated sessions.** A session is *authenticated* on a phone once the other phone's
> establishing message for it -- the opening that created it here, or the reply that made its first
> step -- carried a signature that verified against the other phone's identity key. The book keeps
> this per session (stored state, below).
>
> - **Stage 1 (built 30 Sep 2026):** every reader accepts the flag on any message and checks a
>   signature whenever one is present, refusing a wrong one before anything is kept. Unsigned
>   starts are still accepted, because no build signs yet.
> - **Stage 2 (`SIGN_SESSION_STARTS`, ON since 8 Oct 2026):** writers sign as
>   above.
> - **Stage 3 (`REQUIRE_SIGNED_SESSION_STARTS`, ON since 12 Oct 2026, the owner: builds before 2.950 are not waited for):** an unsigned
>   opening or first step is refused, and **sending never moves to a session that is not
>   authenticated** (the sending rule, below). Sessions accepted unsigned before stage 3 stay in the
>   book and are read, and are **no longer sent on**: when the session a phone would send on is not
>   authenticated -- other than its own session not yet answered -- its next message begins a new,
>   signed session. And **a contact that has spoken through the ratchet is no longer heard without
>   it** (below).
> - **Per contact, before the global stages (`J1633b`, 3 Oct 2026; nobody waits for every phone).**
>   A phone signs its session starts to a contact whose card carries capability **`dss`** ("reads
>   signed session starts", advertised from 2.951), or to one it has already heard signing (a build
>   that signs reads). And once a signed start from a contact has opened, the book latches
>   `heard_signed` (book version 4) and stage 3's two refusals apply **to that contact**: an unsigned
>   opening or first step from it is refused -- the pairing-key thief's start -- and sending never
>   moves to an unauthenticated session with it. Deliberately left to the global stage 3: the
>   static-message refusal and the ratchet kill switch, so no contact is silenced who turned its
>   ratchet off. A contact never heard signing keeps today's rules exactly.
>
> **Static messages from a contact that ratchets (the external review, 1 October 2026).** §4.5's static
> composition rests on the pairing key alone, which never changes. So whoever holds it can write
> "from" a contact -- any payload type, device lists (§5.8) included -- for as long as the pairing
> lasts, however far the ratchet has moved. Each phone therefore records, per contact identity and
> kept in that contact's book, that the contact has spoken through the ratchet; **at stage 3 a static
> message from such a contact is refused**, and the host can no longer switch ratcheting off for it
> (only a new pairing resets it); a phone that loads such a book sends to that contact ratcheted
> without waiting for its host. The local-network proof (`J1571`) is sealed static whatever the
> switch says and is read without `receive`, so it is unaffected. A linked device is its own contact
> identity with its own sessions, so it is judged by its own ratchet, not its phone's. Every delivery
> also says whether it came through the ratchet. **Before stage 3 that bit carries no authentication
> meaning** (`J1837`, outside review R1): a holder of the pairing key can open an unsigned session,
> and every message on it is delivered as ratcheted. A host MUST NOT accept a device list, a card or
> anything else security-sensitive on the strength of it until stage 3 is on. At stage 3 it means the
> message was opened on a session whose establishing message carried a verified signature, on a
> chain the pairing key cannot derive -- that is, the session is `authenticated` (N6). A session
> accepted unsigned BEFORE stage 3 stays readable past its first step and is never sent on, and a
> delivery on it is reported NOT ratcheted at stage 3; its opener's first chain is refused unsigned
> at stage 3 like any other.
>
> **What it does not cover, stated.** The signature binds the ratchet key and the sealed message; it
> does not make the opener's first chain unreadable to a holder of the pairing key. Until stage
> 3 is on, a holder of the pairing key can **read** the opener's first chain (its keys are derived
> from the pairing key), can **forge** unsigned messages on it "from" the opener and spend the real
> ones' keys, can **take over** either phone's sending by opening an unsigned session, and can
> **send static messages as either phone, indefinitely**, as the paragraph above describes. The
> record that would refuse static messages is kept from now, and the refusal switches on with stage
> 3. At stage 3 (`J1837`, R2) a receiver also refuses an UNSIGNED message on the opener's first
> chain -- stage 2 writers sign that whole chain, each message over its own ciphertext (N4), so a
> genuine signature cannot be copied onto a forgery -- and the forgery window closes with the
> takeover; what remains is that the thief can still READ that chain until the opener's first DH
> step -- on every session, including each new one that a restart (`J1842`, `J2535`), a restore or
> crossed openings begin, for as long as the pairing lasts. A session decoded from a version-1 book has no opener key (N5); at import the key is
> recovered where the book can prove it -- the opener's first receiving chain is derived from the
> pairing key and the session id, so a session still receiving on that derivation for the key the
> other side uses has its opener key put back, whether or not this phone has replied; once the
> opener has stepped nothing is derivable and nothing is guessed. A
> backup restore drops the books, and with them the static-refusal record -- so (R4) the host keeps
> "has spoken through the ratchet" on the contact record as well, which a backup carries, and
> re-asserts it into the core after a restore (`set_ratchet_latched`, monotonic); a book imported
> over a slot that had already heard the contact keeps the latch. A phone restored from a backup
> made before the contact ever ratcheted is still open until the contact's next ratcheted message.

**Primitives** — §4.1's table, plus ML-KEM-768 for the one post-quantum mix per session (`J1901`
phase 2, §4.9.5); KDF chains as the specification's §7.2 recommends, with application-specific labels.
X25519 for the DH ratchet; `KDF_RK(rk, dh) =
HKDF-SHA256(salt = rk, ikm = dh, info = "channel/ratchet-v1/kdf-rk")` → 64 bytes (new root ‖ chain);
`KDF_CK(ck)`: message key `HMAC-SHA256(ck, 0x01)`, next chain key `HMAC-SHA256(ck, 0x02)`; message
AEAD ChaCha20-Poly1305 under `HKDF-SHA256(salt = ∅, ikm = messageKey_n, info =
"channel/ratchet-v1/message-key")` with a **random 12-byte nonce carried beside the header**. A message
key is used once, so the nonce is not needed for uniqueness; it is random so that a state rolled back
by a restore still never repeats a (key, nonce) pair.

**Sessions.** Either phone may start one. The initiator picks a random 16-byte `sessionId` and:

```
root0              = HKDF-SHA256(salt = sessionId, ikm = pairing messageKey (§4.5), info = "channel/ratchet-v1/root")
responderSecret0   = HKDF-SHA256(salt = sessionId, ikm = pairing messageKey,        info = "channel/ratchet-v1/responder-key")
initiator:  DHs = fresh key pair;  DHr = X25519-public(responderSecret0);  (RK, CKs) = KDF_RK(root0, DH(DHs, DHr))
responder:  created on receiving an INITIAL-flagged message for an unknown sessionId;
            DHs = responderSecret0;  RK = root0;  then the ordinary receive step
```

Both phones can compute `responderSecret0`, and so can anyone who obtains the pairing key, so the
opener's **first chain of every session is only as secret as the pairing**. This is weaker than X3DH's
first messages: there the responder's first key is a signed prekey whose private half only the
responder holds, while here it is derivable from the pairing key. What the two share is only that the
responder has not yet contributed a fresh DH key. From the first reply on every chain comes from
X25519 keys generated fresh on one phone. Each phone keeps at most **6** sessions per contact. It
remembers the last **256** retired `sessionId`s so a replayed initial message cannot recreate one (the
envelope's own ±1 epoch window limits a replay to thirty minutes, and late mail, §4.6, is accepted only
while the opened-envelope table still remembers every envelope opened since it was sealed). Only a message carrying the
INITIAL flag may create a session.

**Where a phone sends -- one rule (`J1559`, `J1633`, `J1826a`).** A phone sends on the session it most
recently **received** on, as Signal does, so a phone that lost its state and starts a new session is
answered on it at once -- where "most recently" is by the SENDER's clock, the payload's `sentAt` (§5.1),
because arrival order is exactly what a late or reordered envelope gets wrong. Each session keeps the
newest `sentAt` received on it (`any`), and the newest received on a chain the pairing key cannot
derive (`trusted`; every chain but the opener's first, on the responder's side). Receiving a message
that opens on session X, with clock t, moves sending there from the current sending session Y when
(with no Y, or X = Y, it does):

1. **Y has a `trusted` clock:** t is not earlier than it. A session start sealed before the contact's
   latest message on Y is stale -- the contact may have lost that session in a restore since
   (`J1826a`), and answering on it would send into a session nobody holds. It is still read. A late
   message on an older session is older than Y's evidence and moves nothing.
2. **Y has none yet:** t is not earlier than Y's `any`, **and** X was created on this phone no
   earlier than Y **or** the message is itself on a chain the pairing key cannot derive. The first
   keeps a stale start from replacing a session whose opener wrote later; the second brings a phone
   back from a stale start it moved to before the contact's answer on its own session had arrived.
3. **At stage 3, X is authenticated** -- in every case.

Equal clocks move. A message whose clock cannot be read moves sending by the order this phone created
the sessions in. A phone starts a new session only when it has nothing to send on (or, at stage 3,
when what it would send on is not authenticated). **A sender's clock is held to this phone's own
plus twenty minutes** (two epochs) before any of this reads it: an envelope from a sender further
ahead would not open (§4.6 accepts one epoch either side), so no honest clock is changed.

A contact whose clock went backwards across a restore is answered on its new session once its clock
passes its last message on the old one. A thief of the pairing key who can inject a message on an
opener's first chain can give it a future clock; once that session has a `trusted` clock this moves
nothing (rule 1 reads only `trusted`), and before then it holds sending where it is (rule 2) for at
most twenty minutes past the moment it arrived -- a delay, never a diversion onto a chain the thief
can read. The same thief can, with a well-timed message on an older session it can still forge on,
move sending there until the contact's next message moves it back (the external review of this rule,
1 October 2026, C2) -- intermittent loss while the attack lasts, never a readable chain. A
version-1 book has no opener keys, so its clocks all count as `trusted` until its sessions are
replaced; the twenty-minute ceiling bounds what that can cost.

**Not healed by that rule, measured (`J1826a`):** a session start the contact sealed BEFORE this
phone's own restore, opened AFTER it. The restored book is empty, so there is no Y to compare with; the
phone answers on a session the contact has moved past, and both directions fail until a re-pair.
**Wire** — the ratchet's output, which becomes the plaintext of the envelope's own AEAD (§4.5):

```
prefix    = version 0x01 ‖ flags ‖ sessionId 16 ‖ DHs public 32 ‖ PN uint32 BE ‖ N uint32 BE    = 58 bytes
header    = prefix ‖ [ek 1184, only when PQ_OFFER] ‖ [ct 1088, only when PQ_CT]     (58, 1242, 1146 or 2330 bytes)
flags     : 0x01 INITIAL, 0x02 SIGNED, 0x04 PQ_OFFER, 0x08 PQ_CT (J1901 phase 2); every other bit MUST be
            zero (the message is refused)
message   = header ‖ [signature 64, only when SIGNED] ‖ nonce 12 ‖ ChaCha20-Poly1305(plaintext) ‖ tag 16
AEAD key  = HKDF-SHA256(salt = ∅, ikm = messageKey_N, info = "channel/ratchet-v1/message-key")
AEAD aad  = the WHOLE header, extensions included ‖ "channel/ratchet-v1/ad" ‖ sender identity 32
            ‖ recipient identity 32 ‖ packetId 16
signature = Ed25519(sender identity key, "channel/ratchet-start-v2\0" ‖ sessionId 16 ‖ DHs public 32
                                         ‖ sender identity 32 ‖ recipient identity 32
                                         ‖ SHA-256(whole header ‖ nonce 12 ‖ ciphertext ‖ tag) 32)
```

`J3748` (core review, 1 November 2026): this block named only the 58-byte prefix and two flags until
then, although `ratchet.rs` has carried the J1901 phase 2 extensions since October. `ek` is the
sender's ML-KEM-768 encapsulation key, `ct` a ciphertext for the peer's offered `ek`
(`designs/J1901-phase2.md`); the order is `ek` then `ct`, and a reader takes each length from its flag,
never from the message. Both the AEAD and the signed digest cover the extensions.

The associated data binds the message to its envelope: the sender, the recipient, and the envelope's
`packetId` (§3), none of which is carried inside the ratchet message. The signature is between the
header and the nonce and outside the AEAD; `vectors/12-ratchet-signed.json` has one signed opening,
byte for byte, and `vectors/13-ratchet-kdf-and-trace.json` each derivation above on its own plus an
unsigned trace with out-of-order delivery, a key kept at a DH step, and PN on both sides (`J1833`).

**The DH step in two halves (`J3765` stage 1).** A message bringing a new ratchet key `DHr'` runs only
the receive half when it is opened: `(RK, CKr) = KDF_RK(RK, DH(DHs, DHr'))`, and the ML-KEM
decapsulation if it carries a ciphertext. Opening draws no randomness, so every copy of a profile that
opens the same messages ends in the same state. The send half is owed, and runs at the next encrypt
(or reserve): a fresh `DHs'` is drawn ahead of that encrypt's nonce, `(RK, CKs) = KDF_RK(RK, DH(DHs',
DHr'))`, and the encapsulation to the peer's offer kept from the opening. While a step is owed, a
second new ratchet key is refused, since an honest peer cannot send one before it has heard `DHs'`.
The wire, the derivations and their order are what they were; only when `DHs'` is drawn moved.
`vectors/70-ratchet-two-copies.json` has two copies opening the same trace and agreeing.

A relay sees none of this: it is inside the envelope's existing encryption, so a ratchet key or a
counter can never link two envelopes. Someone who later steals the pairing secret can read the headers
of recorded envelopes. What else they can read depends on how far the session has ratcheted: messages
sent after the first DH step has completed stay unreadable to them, but the opener's first chain is
derived from the pairing key and so stays readable to a holder of it (see "What it does not cover,
stated" above). Confidentiality after the first step, forward secrecy and recovery after a compromise are
separate properties and are claimed separately.

**Out of order, bounded.** The mailbox and the radios are parallel routes, so a message may overtake
many older ones. A receiver derives and keeps the skipped message keys, **at most 2,000 ahead in one
chain** — checked before a single key is derived, because a header is hostile input — and **at most
4,000 kept per contact**, oldest dropped first. A kept key is deleted when used. When a new ratchet key
arrives and the old chain is further behind than 2,000, its missing messages are **given up** and the
step still happens; refusing the step would strand every later message on that session.

**State changes only on success,** on both sides. A receiver works on a copy and keeps it only if the
message authenticated; a sender likewise, so a seal that fails (the random source refusing) leaves no
gap in the chain. A forged message, a replay, a counter out of bounds or a low-order ratchet key (all-zero
X25519 output, refused) leaves the state exactly as it was.

**Stored state** is an opaque, versioned byte string (`Book::encode`) the app keeps per contact, sealed
at rest like the rest of its store. It is **excluded from backups**: a restored phone begins new
sessions rather than resuming stale ones, and the other phone heals onto them as above. **Version 2**
(`J1633`) adds, per session, whether its current sending chain is signed, whether it is authenticated,
the opener's first ratchet key (on a session this phone responded to) and the two newest sender clocks
(`trusted`, `any`); and, for the whole book, whether the contact has spoken through the ratchet. A
version-1 book still decodes: its sessions read as not authenticated, with no clocks, no opener key
(so every chain's clock is `trusted`), and signing on until each session's next DH step -- signing a
message that did not need it costs 64 bytes, while leaving a responder's first chain unsigned would
have it refused at stage 3 -- and the contact counts as having spoken through the ratchet when any
session has a receiving chain, which only its ratcheted message makes. Every count is bounded before
anything is allocated, as in version 1.

**A rolled-back book is retired, not resumed (`J1837`, outside review M2).** The book's logical clock
(`Book::clock`, advanced by every key-consuming operation: begin, respond, encrypt, decrypt) is its
generation. The app records it on the contact record each time it stores the book -- the record is
in every backup, the book in none -- and hands it back at import (`Node::ratchet_import_checked`).
A book whose clock is below the record is a restored or stale copy, and every session in it is
retired before it can re-seal under a message key the newer copy already spent or re-open a
message already delivered: the next send begins a fresh session, which the contact heals onto as
above, and a replay on a retired session is refused. The latch survives, and the clock is raised to
the record's so the next export agrees with it. A book stored by a failed save and a relaunch is
the case this guards; a phone restored from a backup made before any book existed has no record
and no book, and begins afresh as before.

**What the record carries, and what it does not close (`J1837`, N12/N13).** The clock alone retires
only the sessions the stale copy knew, so the app records the live session ids beside the clock
(`Node::ratchet_session_ids`; as the union of every read, newest first and bounded, since a book
prunes past six live sessions) and hands them back after a rollback, or when the record is present
and no book is (`Node::ratchet_retire_sessions`):
a replay of the opening of a session the newer copy created after the copy was made is then refused
instead of re-creating it. An attachment key already held when a book is upgraded from version 1 or 2
reads as unauthenticated, since the older book cannot say which session carried it. **Healing a double break (`J1842`).** When this phone's own-mailbox envelopes from a keyed
contact open under no key, the host stops SENDING on the current session for that contact
(`Node::ratchet_restart_sending`), keeping every session readable; the next message is then an opening,
which the contact can read with the pairing key alone, and `received_on` moves the contact's sending
onto it as the newest session it has heard. Two books that each seal on a session the other lacks --
a lost book on one side, a restored older copy on the other -- heal without a re-pair. Bounded as the
card re-send it accompanies: own mailbox only, keyed contacts only, once an hour per contact. **A ratcheting contact who writes static (`J2535`).** The same restart, reached by a second signal: an envelope from a contact's OWN session that opens STATIC (not through the ratchet, and not a `LinkRenewal` carrier), from a contact this phone has heard through the ratchet before (the record's latch), while this phone seals ratcheted to them. That contact holds no book for us -- it lost it, and the card that would switch its ratchet on cannot open for it -- so every message on our session is refused there, while its static ones open here; nothing else on either side ever moves (a static message does not touch our book, nothing fails to open here, and its own `J1842` restart has no slot). The opening the restart produces is a RATCHET message, not static, so it is read with the pairing key alone and is not subject to stage 3's static refusal; the contact keeps a book from it and answers through it. Once an hour per contact, shared with `J1842`'s clock. At stage 3 a latched contact's static never opens, and `J1842` covers the same state. A kept message key of a session whose opener
key the book does not hold (one this phone began, or one upgraded from version 1 after the opener
stepped) is, when about to be spent unsigned at stage 3, re-derived from the pairing key and the
session id exactly as a forger would derive the opener's first chain -- `KDF_RK(root0, DH(responder,
opener))` walked to the key's index -- and refused if it matches; a key from any other chain mixes a
random secret and cannot match, so it is spent as before. **Version 3** of the stored book adds, after the latch, the ids of received
attachment keys whose chunk 0 came on a session that was not established under a verified signature
(N11), so a later chunk reports as its chunk 0 did; a version-2 book reads every held key as
authenticated.
**Version 4** (`J1633b`) adds, for the whole book, `heard_signed`: a session start from this contact
has opened under a verified signature (the per-contact stage-3 latch above); older books read false.
**Version 5** (`J1901` phase 2) adds, per session, the seed of this phone's offered ML-KEM-768 key pair,
the other phone's offered `ek` not yet answered, the ciphertext the current sending chain carries, and
whether the session has mixed; and, for the whole book, `heard_pq` (some session with this contact has
mixed), a record for reports rather than a switch. **Version 6** (`J3765` stage 1) adds, per session,
whether a send half is owed and the peer `ek` that send half will encapsulate to; every older book
reads as owing nothing. `Book::decode` reads versions 1 to 6 and `Book::encode` writes 6.

**Reserved slots (`J1964b`).** `Book::reserve` (`Node::ratchet_reserve`) sets aside 1 to 8 sending
indexes on the session the book sends on, so that a process holding no book (the iOS notification
extension) can seal one message per index, a delivered receipt. A slot is that index's frozen header
plus its message key and the book's clock, nothing else; it seals with a fresh random nonce, no
signature and no post-quantum fields, and a stored slot whose header carries any flag is refused. The
chain moves past the reserved indexes, so an unused slot looks to the receiver like a lost message.
Reserving completes an owed send half first, and is refused, leaving the book unchanged, when nothing
is sending, or the sending session is still unanswered (`INITIAL`), or its sending chain must be signed,
or it has no receiving chain, or its chain carries a post-quantum ciphertext. The host MUST save the
book before it stores the slots: the reverse order is the one way an index could be used twice. Until
used, up to 8 message keys live outside the book, wherever the host stores the slots; the host is
expected to drop a slot whose session is no longer the one the book sends on
(`Book::reserved_slot_is_current` is the check).

**Not everything a phone seals is a message.** The local-network handshake (§7, `LANPeerAuthenticator`)
seals a fresh random nonce to every contact on every connection, to prove the sender holds that
contact's key. It is sealed under §4.5's static composition, **never through the ratchet** (`J1571`):
ratcheted, each handshake would advance the chain to every contact by a message none of them receives
(most of them are not on that network), leave each a gap of skipped keys, and change every stored
book. The proof carries nothing to protect, and a static seal proves possession of the key as well.
The ratchet's switch for that contact is off for that one seal only; the next message sets it by the
ordinary rule.

**The proof answers this connection's key agreement (`J2517a`, hello v2; replaces `J1572`'s
challenge).** Every local-network and Wi-Fi Direct connection begins with a fresh X25519 key
agreement, the proof signs its transcript, and every frame after it is sealed under the agreed
key -- so a proof belongs to one connection and the connection belongs to whoever made the proof.

*Threat model.* A stranger M on the same network, able to open sockets to any phone and to read,
drop, delay and rewrite everything on its own sockets; every phone answers any socket's hello.
Under `J1572` the challenge was plain bytes, so M passed Bob's challenge to Alice's phone on a
socket of its own, passed her answer back, and Bob bound **M's socket** to Alice (`J2517a`): M then
received Alice's mail, which it cannot read, and dropped it, while Bob's newest-proved rule
(`HelloBindings.choose`) preferred M's socket to Alice's own. Out of scope: an on-path party that
carries a connection's bytes and can stop carrying them -- any router can, and so can anyone on the
LAN who spoofs ARP. What this closes is M being **bound with traffic of its own**.

*The exchange* (`mesh-core-rs` `lan_hello`, the only implementation; vector `33-lan-hello`):

1. **Hello key** (frame kind `5`): each side draws an ephemeral X25519 key pair for this connection
   only and sends the 32-byte public key, first, in the clear. Only the first is read; any other
   length drops the connection. A key equal to this side's own (reflection) or one whose shared
   secret is all zero (a low-order point) is refused and the connection dropped.
2. **Transcript and keys**, the same on both sides because the two keys are sorted (no initiator or
   responder role -- both phones may dial each other):

   ```
   e_lo, e_hi = the two public keys, lowest first (bytewise)
   th         = SHA-256("channel/lan-hello-v2/transcript" ‖ e_lo ‖ e_hi)
   okm        = HKDF-SHA256(salt = th, ikm = X25519(mine, theirs),
                            info = "channel/lan-hello-v2/frame-keys", L = 64)
   key sent with by the holder of e_lo = okm[0..32];  by the holder of e_hi = okm[32..64]
   ```
3. **Proof**: one per contact, sealed exactly as before (§4.5's static composition, never the
   ratchet, `J1571`), whose plaintext is

   ```
   "\0chm-lan-auth-v1\0" ‖ "t:" ‖ hex(th)          (exactly 83 bytes)
   ```

   and a verifier accepts a proof only when it opens under the key of the contact it names
   (`Node::probe_proof`) and the plaintext is exactly that, `th` being **this connection's**
   (constant-time compare). The v1 marker stays the prefix, so every message store keeps dropping
   it from message traffic. Proofs are answered at most thirty a minute across all connections,
   once per connection, as before -- each answer is one seal per contact.
4. **Every frame after the hello key is sealed** (frame kind `6`):

   ```
   payload = ChaCha20-Poly1305(sendKey, nonce = 0x00000000 ‖ u64be(counter),
                               aad = "channel/lan-hello-v2/frame",
                               innerKind ‖ innerPayload)
   ```

   with one counter per direction from zero, advanced by one per frame. The proof (inner kind `1`),
   envelopes (`2`) and encounter frames (`3`) travel ONLY this way; a cleartext frame of any kind
   after the hello key is ignored. A sealed frame that does not open -- tampered, injected,
   replayed, reordered, or one before it dropped -- ends the connection and every later call on its
   session refuses, so a desynchronised connection cannot be nursed along. The counter nonce is the
   standard construction for a key used in one direction of one connection (Noise's transport
   phase): it never repeats under a key, and `J898k`'s "bind the nonce to the complete input" rule
   is for DERIVED nonces under a long-lived key, which this is not. A sealed frame is at most
   `maxFrameBytes + 17` (the inner kind and the tag), so the inner limit is unchanged.

*Why the relay now fails.* M has a socket to Bob and one to Alice. If M sends its own hello key to
each, the two transcripts differ (each contains one of M's keys and only one of theirs), so Alice's
proof -- which M can carry, re-sealed under its own frame key with Alice -- answers the wrong
transcript and Bob refuses it. To make the transcripts equal M must send Alice's key to Bob and
Bob's to Alice, and then M knows neither private half and cannot derive the frame keys: it cannot
read a frame, write one, or drop one without the next failing to open, which ends the connection.
At that point the socket is a pipe between Alice and Bob -- the out-of-scope router above -- and
not a peer of M's. **Reflection still fails** two ways: a reflected hello key is refused, and a seal
TO a contact does not open as FROM that contact.

*What stays.* A pipe that stops carrying stalls rather than lies, which is why `J2517`'s tick rule
stays: a local-network write earns no tick until the receipt, and the 8-second rescue moves an
unanswered one to the Internet.

*Compatibility: a clean break* (the owner, 8 Oct 2026: backward compatible "when you can be ...
without much problem", and never waiting on testers). Speaking both versions would let M strip the
hello key and force the old, relayable hello -- a downgrade -- unless the old one bound nobody, and
then it buys nothing. So a build with hello v2 sends no challenge (kind `4`), ignores one, and
accepts only transcript proofs; with a build before it the two phones have no local-network or Wi-Fi
Direct link and use Bluetooth and the Internet until both are updated.

### 4.5b Forward secrecy for large attachments (`J1559` stage 4)

A streamed attachment's chunks (§5.6a) must re-seal byte for byte, and a ratchet spends a fresh key on
every seal -- so the chunks cannot each go through §4.5a. Instead, for a contact who is sent ratcheted:

- **Chunk 0 is ratcheted** and carries the attachment's own key ahead of its payload. Inside the
  ratchet message: `0x02 ‖ attachmentId 16 ‖ attachmentKey 32 ‖ padded payload`.
- **Every later chunk is sealed under that key**, and the result is what the envelope's AEAD seals:

```
0x01 ‖ attachmentId 16 ‖ nonce 12 ‖ ChaCha20-Poly1305(attachmentKey, nonce,
                                        aad = "channel/attachment-v1/aad" ‖ 0x01 ‖ attachmentId,
                                        padded payload)
nonce = HMAC-SHA256(attachmentKey, "channel/attachment-v1/nonce" ‖ padded payload)[0..12]
```

The key is 32 random bytes, made the first time any of the attachment's chunks is sealed and kept in
the sender's ratchet book, so a re-seal after a relaunch uses the same one. **The nonce is a function
of the complete AEAD input** -- the plaintext, and an AAD that is fixed for the key, because the key
belongs to one attachment and the AAD names it -- as §5.6a's is: identical bytes re-seal identically, and two different
plaintexts -- the sender's clock moves between re-seals (`J898k`) -- can never share a nonce. A
receiver recomputes it and refuses a chunk whose nonce does not match.

A receiver believes a carried key **only when the ratchet message's payload is chunk 0 of that same
attachment**, and keeps nothing -- not the advanced ratchet, not the key, not the switch to sending
ratcheted -- unless the whole message is good. It keeps received keys apart from the keys of
attachments it is sending, so a contact cannot replace a key this phone is using (at most 256 of each
per contact, least recently used dropped, and a dropped received id is remembered). A second chunk 0
naming a different key for an attachment already keyed is refused.

**Chunk 0 takes a fresh packet id on every seal**, because it is ratcheted and no two seals of it are
the same message; a derived id would make a retry look like a duplicate of a first copy that failed to
open. **A later chunk that arrives before chunk 0 cannot open yet.** It is refused and, for this cause
only -- not for a key that was dropped -- the receiver forgets having seen the packet, so the sender's
retransmission, byte-identical by design, opens once the key has arrived. A contact who is not sent
ratcheted gets exactly what every earlier build sealed.

*This section's first version had five defects, found by an adversarial review the same day and each
fixed with a test that reproduces it.*

> **Design note for a later version -- not built (`J1833`, outside review I2).** One key does two jobs
> here: `attachmentKey` is the HMAC key for the nonce and the ChaCha20-Poly1305 key for the seal.
> That is not exploitable with these two primitives, but §5.6a separates the same two jobs with
> HKDF, and this layer should match it. The next version of this layer would expand two keys from
> the carried one -- `sealKey = HKDF-Expand(attachmentKey, "channel/attachment-v2/seal", 32)` and
> `nonceKey = HKDF-Expand(attachmentKey, "channel/attachment-v2/nonce", 32)` -- under a new marker
> byte, so a receiver tells the versions apart before it opens anything. The same version would also
> bind this layer's AAD into its nonce, as §5.6a now does: today the nonce covers the plaintext only,
> which is safe solely because the AAD is fixed for the key (the key belongs to one attachment, and
> the AAD is that attachment's id) -- a variable field added to it would reopen M1 here. Unlike §5.6a's nonce, this
> one IS recomputed and checked by the receiver, so the change is a wire change: readers first,
> gated on the contact's capability, as every change of this kind here.

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

**Late mail: the envelope's own sealing epoch (`J1950`).** A destination token outside the window MAY
still be accepted if it equals the receiver's destination token for the envelope's `creationBucket`
(§3.2), under ALL of these conditions:

1. `creationBucket` lies in the past beyond the window: `creationBucket + 1 < nowEpoch`. A sealing
   epoch in the receiver's future beyond the window is never matched — a fast clock is the window's job.
2. `nowEpoch − creationBucket ≤ MAX_RELAY_LIFETIME` (144 buckets, one day), whatever `expiresAfter`
   the envelope claims; an expired envelope (§3.3) is refused before any match.
3. The receiver's opened-envelope table (§7.3), which remembers what it opened for `MAX_RELAY_LIFETIME`,
   **can answer for everything recorded since one epoch before `creationBucket`**: it was already
   recording then (a table that has recorded nothing vouches for nothing), and it has pushed no live
   entry recorded since then out to stay within `MAX_SEEN_ENTRIES` (judged by the earlier of the
   table's own latest time and the current one). The instant from which it can answer is stored with
   the table; a table stored before that existed answers only from when it was loaded. Otherwise the
   window alone decides.

`creationBucket` is inside the AAD, so trusting it before the AEAD decides only which ONE extra token
to derive; a false value fails to match or fails to open. Condition 3 is the reason this is safe: the
±1 window used to be a replay bound as well as a skew allowance (nothing older than thirty minutes
matched), and late mail moves the whole of that job onto the opened table — so it is accepted only
while the table can still answer for every envelope opened since it was sealed.

Why it exists: §8's mailbox is addressed on a six-hour clock precisely so that mail deposited while a
phone sleeps can be FOUND, and the relay deletes what a phone collects. Without this rule a phone that
collected after an hour asleep found its mail, could not match it, and lost it.

A node that carries mail for others (§7.7a) treats an envelope as its own — never to be carried — when
its token matches at `creationBucket` under conditions 1 and 2, whatever the table remembers.

**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.7a What travels on the payload characteristic (`J1779`)

Below the packet layer, and never seen by it. A packet frame larger than one ATT
write or notification is split into **chunks**, each with an 8-byte header:

```
transportPacketID  4 bytes, big-endian   random per send; unrelated to §2's packetID
fragmentIndex      2 bytes, big-endian
fragmentCount      2 bytes, big-endian   1 ..= 4096
payload            the rest
```

A receiver reassembles by (link, `transportPacketID`, `fragmentIndex`) and
discards anything under 8 bytes unread.

**Reassembled ack.** When a transport packet has reassembled and been handed on,
the receiver writes exactly 5 bytes back on the same link — a notification from
the peripheral side, a write without response from the central side:

```
0xAC  transportPacketID (4 bytes, big-endian, as in the chunk it answers)
```

It is distinguished from a chunk by its length (every chunk is at least 8
bytes), not by its first byte, which a random `transportPacketID` shares one
time in 256. A 5-byte frame with any other first byte is discarded. A sender
waits for the ack only from a peer whose contact card carries the capability
`bak`, and treats none within 1.5 s of its last chunk being accepted as a failed
send on this link. Builds without the capability discard the 5-byte frame as a
too-short chunk.

### 4.7b The L2CAP data path (`J1771` (4), prototype, off by default)

An alternative to §4.7a for **sealed envelopes only**, behind a switch that ships
off on both platforms (`BLEL2CAP.isEnabled`, `BleL2cap.IS_ENABLED`). Discovery,
PRESENCE, encounter frames, pairing frames and the reassembled ack stay on the
payload characteristic. Delivery receipts and chunk-zero acknowledgements are
sealed envelopes and ride the channel like any other, with their mechanisms
unchanged; an envelope that arrives on the channel is not answered with a
reassembled ack, and a sender on the channel does not wait for one.

**The PSM.** A phone with the switch on publishes an unencrypted LE
credit-based channel listener (iOS `publishL2CAPChannel(withEncryption: false)`;
Android `listenUsingInsecureL2capChannel()`, API 29 and up) and adds one
readable characteristic to the §4.7 service:

```
PSM characteristic:  ABDD3056-28FA-441D-A470-55A75A52553A   (Apple's CBUUIDL2CAPPSMCharacteristicString)
value:               the PSM, 2 bytes, little-endian, 0x0001 ..= 0x00FF
```

A reader treats any other length or value as no PSM. Unencrypted is deliberate:
there is no Bluetooth pairing between these phones, and every envelope on the
channel is already sealed end to end.

**Who opens.** Only a central, only on a link whose payload characteristic is
already up, and only when every contact bound to the peer (`J595`) has a card
carrying the capability `l2c` — which a build advertises only while its switch
is on. A peer bound to nobody is not tried; the PSM is read again when a binding
is learned on a link already up. An open that has not completed in 5 s is given
up. A channel, once open, is used in both directions; where both phones open one
at once, the first stays.

**Framing.**

```
length   4 bytes, big-endian   1 ..= 65,536 (MAX_ENVELOPE_BYTES)
body     length bytes          one sealed envelope
```

A receiver checks the length the moment its four bytes arrive, before waiting
for or sizing anything for the body; zero or anything over 65,536 closes the
channel. So does a stream that ends inside a frame. A frame whose body is a
pairing flight or an encounter frame is discarded, not demultiplexed.

**Failure.** A frame the sender's stack has not taken within 5 s closes the
channel (a half-written frame cannot be withdrawn). Whatever the cause, the
envelope that did not go is sent by §4.7a instead, and the fallback is counted.
A stream write completing means the sender's stack took the bytes, not that the
receiver's app filed them, so the app-level acknowledgements are unchanged.

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

---

### 4.9 CryptoSuite v2 — hybrid X25519 + ML-KEM-768 (`J1901`)

**Status (3 Oct 2026): bundle v2 and invitation v5 are on the wire from 2.950, and handshake v3 from 2.951, on both platforms** -- v3 sent only to a displayer whose code's attempt nonce carries the 3b marker (`pairing_code::nonce_answers_v3`, vector 24, `designs/J1901-v3-negotiation.md`) or an identity already known to speak post-quantum, because an initiator of v3 never falls back. Rollout step 3 is live; step 4 is not. In the core: `pq.rs` (the
ML-KEM door, cross-checked against RustCrypto's FIPS 203), bundle v2 (`prekey.rs`
`sign_bundle_v2` / `verify_bundle_v2`) the v3 agreement (`as_sender_v3` / `as_recipient_v3`) and invitation v5 (`pairing_code.rs`
`encode_v5`, `decode`, and `recipient_keys_with` as the one picker, driven per record by the prekey ring: v5 is opened with v3 and nothing else; `J3752` removed the bare-secret export, ABI 5). Vector 21
(`vectors/21-pq-prekey.json`) pins all of it, its ML-KEM values computed by `vectors/mlkem768.py`, a
third implementation written from FIPS 203; libcrux, RustCrypto's `ml-kem` and it agree byte for byte. It replaces §4.1's table
wholesale when it ships, as §4.1 requires: the suite itself has no negotiation and no optional PQ. The
ratchet's in-session mix (`J1901` phase 2, §4.5a, §4.9.5) is the one exception: it is offered only to a
contact whose card carries capability `pqr`, and a session with a contact whose card lacks it mixes no
post-quantum secret.

**The threat it answers is "record now, decrypt later".** Every secret in this protocol descends from
one X25519 agreement at pairing (§4.2, §6.5, §6.7). Somebody who records a pairing today and holds a
quantum computer later recovers `sharedSecret`, and with it every key under it: routing, message
keys, and the Double Ratchet's first chain (§4.5a). The ratchet does not save the later messages,
because each ratchet step mixes an X25519 output into the root key, and the root key starts from
`sharedSecret`. **Mixing a post-quantum secret into `sharedSecret` protects the whole tree**: the root
key then starts from a value the recording cannot recover, and every later ratchet step only adds to
it. It does NOT give post-quantum authentication: Ed25519 still signs identities and transcripts, so
an attacker who has a quantum computer *at pairing time* can still impersonate. That is a different,
later threat, left to a CryptoSuite v3.

#### 4.9.1 The table

| Purpose | Algorithm |
|---|---|
| Identity signing | Ed25519 (unchanged) |
| Key agreement | X25519 **and** ML-KEM-768 (FIPS 203), always both |
| Key derivation | HKDF-SHA256 (the combiner too) |
| Token derivation | HMAC-SHA256, truncated |
| AEAD | ChaCha20-Poly1305 |
| Hash | SHA-256 |

ML-KEM-768: encapsulation key `ek` 1184 bytes, ciphertext `ct` 1088 bytes, shared secret 32 bytes.
The implementation is `libcrux-ml-kem` (already in `Cargo.lock` through OpenMLS), whose portable code
is formally verified (hax/F*) and which takes the caller's randomness, so `vectors/` stay
deterministic. Its Arm NEON path is not verified; the core uses the portable path only, and a build
check proves which one is linked. HKDF-SHA256 is the combiner even though ML-KEM uses SHA-3 inside:
SHA-3 stays inside the primitive and does not join the suite.

**The combiner rule, everywhere:** the input keying material is always the classical secret(s) then
the KEM secret, and the HKDF `info` always carries **`ek` and `ct` in full**. Binding both is what
stops a substituted ciphertext from producing a secret one side can predict while the X25519 half
stays honest. Neither half is ever used alone, and a failure of either aborts.

#### 4.9.2 Prekey bundle v2

`[0x02 ‖ 32 Ed25519 identity ‖ 32 X25519 prekey ‖ 1184 ML-KEM ek ‖ 64 signature]` = 1313 bytes.
The signature is over a **labelled** statement, `"channel/prekey-bundle-v2\0" ‖ X25519 prekey ‖ ek`
(its own label: `channel/prekey-v2` is already the v2 invitation's HKDF `info`), never
the bare prekey (which also closes `J1833`'s cross-protocol signing note). The ML-KEM decapsulation
key pair is generated from a **64-byte** seed (FIPS 203's `d ‖ z`). For a prekey made before the
prekey ring (a LEGACY record, §6.7.4) that seed is HKDF-SHA256 of the stored prekey secret under
`channel/prekey-mlkem-seed`. A record a rotation makes (INDEPENDENT, `J2895`) carries **its own 64
random bytes** instead, stored beside its X25519 secret: the X25519 prekey is published, a quantum
computer recovers its secret from it, and a seed derived from that secret gives up the decapsulation
key with it (outside audit of 15 Oct 2026, H-2). A verifier never checked how `ek` was made, so the
wire is the same for both. `prekey_ring::legacy_kem` is the one place the core still derives the seed
(preflight `kem seed independent`); `vectors/54-prekey-ring.json` pins both kinds. `verify_bundle`
reads both versions. The relay's `prekey.mts` accepts 129 bytes (versions 1 and 3) and 1313
(version 2), and since 22 Oct 2026 (relay audit run-1, `relay/prekey-claim-copied-identity`) it also
verifies the signature with this same `verify_bundle` / `verify_bundle_v2`, through the core's wasm
build (`prekey_bundle_verified`), before the `J1036` value claim: the claim owner is the identity
that signed the prekey, not 32 public bytes copied from a GET. A bundle that fails is refused exactly
as a misshapen one is (400 `{ok:false}`).

#### 4.9.2a Prekey bundle version 3 -- the 129 bytes, labelled (`J2895`, outside audit M-3)

`[0x03 ‖ 32 Ed25519 identity ‖ 32 X25519 prekey ‖ 64 signature]` = 129 bytes, every stride and
bound of version 1 (rosters, device lists v1 and v2, invitation v4, the card's `pkb`, the relay's
row). The signature is over `"channel/prekey-bundle-v3\0" ‖ prekey` (57 bytes), never over the bare
32-byte prekey, so the identity key signs no bare 32-byte value for this bundle. It is signed only
through `meshcore_prekey_ring_bundle`, which takes the ring (the prekey SECRET) and derives the public
half, so it cannot sign a caller's bytes. `prekey::verify_bundle`, the core's one verifier of a
129-byte bundle, reads version 1 and version 3 and refuses either one relabelled as the other;
`meshcore_prekey_verify_bundle` (JNI `prekeyVerifyBundle`) is the apps' door to it.
`vectors/55-prekey-bundle-v3.json` pins the bytes. Readers ship first (§4.9.6): a version-3 roster
entry or v4 sent to a build that reads only version 1 is dropped silently.

#### 4.9.3 Invitation v5 (§6.7)

v4's layout, the sender's bundle v2, and the recipient-directed `ct` (1088) after the X25519
ephemeral. Agreement:

```
dhStatic    = X25519(senderPrekeySecret, recipientPrekey)
dhEphemeral = X25519(ephemeralSecret,    recipientPrekey)
ssKEM       = ML-KEM.Encaps(recipient_ek)   /   ML-KEM.Decaps(recipient_dk, ct)
okm(64) = HKDF-SHA256(salt = none,
                      ikm  = dhStatic ‖ dhEphemeral ‖ ssKEM,
                      info = "channel/prekey-v3" ‖ ephemeral ‖ recipientPrekey ‖ senderPrekey
                             ‖ senderIdentity ‖ recipientIdentity ‖ recipient_ek ‖ ct)
sharedSecret = okm[0..32];  pairingNonce = okm[32..64]
```

The sender's own `ek` is in its bundle (one bundle shape everywhere) and is NOT in this KDF: the
recipient never encapsulated to it. Every v2 check stays (self-invitation, own prekey offered back,
ephemeral equal to a prekey, non-contributory X25519), plus: `ct` exactly 1088 bytes, recipient `ek`
not equal to the sender's. **A v5 is never derived with the v1 or v2 agreement**, the rule §6.7.3
already states for v4. Size: about 2.6 KB plus the sealed first message, inside the relay's 64 KB.

#### 4.9.4 Handshake v3 (§6.5): v2's commitment plus one KEM

Built on Handshake v2 (§6.5.9), not v1, so the commitment survives. New packet types; types 11-13 and
16-18 stay exactly as they are.

- **OFFER** (initiator): v2's commitment, nonce, generation. Unchanged in shape.
- **ACCEPT** (responder): v2's fields plus a **fresh, per-attempt** ML-KEM `ek` (never the published
  prekey's), signed in the transcript. No decapsulation key work for a forged offer: generating `ek`
  is the only PQ cost, the same order as v2's X25519 key.
- **CONFIRM** (initiator): v2's reveal and signature plus `ct = Encaps(ek)`, with `ct` inside the
  signed transcript.
- The responder checks the reveal against the commitment and the signature over the transcript
  (which binds `ek ‖ ct`) **before** decapsulating. ML-KEM's implicit rejection means a decapsulation
  that "succeeds" proves nothing; the signature is the authentication.

```
dh    = X25519(myEph, theirEph)                       // abort if non-contributory, as today
ssKEM = Encaps(ek) / Decaps(dk, ct)
sharedSecret = HKDF-SHA256(salt = none, ikm = dh ‖ ssKEM,
                           info = "mesh/handshake-pq-v1" ‖ idPubLow ‖ idPubHigh ‖ ephLow ‖ ephHigh
                                  ‖ nonceLow ‖ nonceHigh ‖ generation(u32 BE) ‖ ek ‖ ct)[0..32]
```

**The confirmation code is computed over the X25519 secret alone, exactly as in v2 -- never over the
hybrid `sharedSecret`.** The initiator chooses `ct` in its LAST flight, after seeing the responder's;
had the code covered the KEM half, a man-in-the-middle could grind `ct` (about 10^12 encapsulations)
until the two legs' codes matched, which is the grind v2's commitment exists to stop (external review
00102975, 3 Oct 2026). Committing to the encapsulation randomness instead cannot work: revealing it
would reveal the KEM secret. With the code over the committed X25519 secret, matching codes mean no
man-in-the-middle, so the signed `ek ‖ ct` are the two honest parties', and the KEM half of
`sharedSecret` is theirs. **That argument holds only where somebody compares the codes, and since
`J348` nobody does** (§6.5.8): against a pairing code substituted in a single channel, the KEM half is
the attacker's on each leg exactly as the X25519 half is. The rule stays as built; it costs nothing and
protects nothing a user can see.

**The transcripts** are new, fixed-length objects under their own label, `mesh/handshake-v3/transcript`,
never v2's extended in place. There are two, because the responder signs before the ciphertext
exists: `transcript_v3(role) = label ‖ v2's fields in v2's order ‖ ek ‖ role byte`, which the
responder signs with role `0x01` in ACCEPT, and `initiatorSigned_v3 = transcript_v3(0x00) ‖
initiatorEph ‖ ct`, which the initiator signs in CONFIRM. Compile-time length assertions hold both.
Per-attempt KEM randomness is a separate 64-byte `KemEntropy` beside v2's `Entropy`: the responder's
key pair is generated from all 64 bytes, the initiator encapsulates with the first 32; neither is
ever reused. Built 3 Oct 2026 (`handshake.rs` `initiate_v3` / `respond_pq`, frames 21-23).

One KEM, not two: the threat is recording, and one bound KEM secret in the agreement already puts
`sharedSecret` out of a recording's reach; a second would double the bytes on the radio for
contributory PQ that this suite does not claim. `pairingNonce`, `pairingSalt` (§4.2) and the
confirmation code keep their v2 definitions (the code over the X25519 secret, above). Frame bounds: ACCEPT about 1,350 bytes, CONFIRM about 1,250. Every pairing transport
already chunks opaque packets (BLE's 20-byte unnegotiated writes included), so no new fragmentation
is needed, only the new bounds in `PacketType::max_bytes`.

#### 4.9.5 What stays classical, said plainly

- **Contacts paired before v2.** Their `sharedSecret` came from X25519 alone and stays so; nothing
  re-derives it. A re-pair upgrades a contact. The in-session upgrade (one ML-KEM-768 secret mixed
  into the ratchet's root per ratchet session) is `J1901` phase 2 (`designs/J1901-phase2.md`), on the
  wire from 2.952 (answering) and 2.954 (offers, `ratchet.rs` `PQ_OFFERS`, sent to a contact whose card
  says `pqr`). It protects the CONTENT of messages after the mix against a recording; the outer
  envelope stays under the pairing key.
- **Group rosters.** A roster carries each member's 129-byte bundle, 24 × 161 bytes under a 4,200-byte
  cap. 1313-byte bundles would make it about 32 KB, against a 32,768-byte decompressed payload limit,
  and ML-KEM keys do not compress. Rosters keep the v1 bundle; a pairing introduced through a group is
  classical until rosters carry identities only and members fetch bundles from the relay.
- **MLS groups** use OpenMLS's own ciphersuite, which is classical; changing it is OpenMLS's suite
  choice, not this one.
- **Authentication** (Ed25519) everywhere, as above.
- **Recovery after a later theft.** Each ratchet step's fresh secret is X25519 alone, so a phone
  stolen after pairing, together with a quantum computer, reads the messages that follow. Phase 2
  (`designs/J1901-phase2.md`, one ML-KEM mix per ratchet session) protects RECORDED traffic; it does
  not change this. Continuous re-mixing would, and is not designed.
- **Invitations v5 sealed to a LEGACY prekey record** (§4.9.2): its ML-KEM key is derived from the
  published prekey's secret, so a recording of such an invitation has no protection against a quantum
  computer. A v5 sealed to an INDEPENDENT record has it. A rotation (§6.7.4) supersedes the LEGACY
  record, and pruning (when on) deletes it 30 days later.
- So, plainly: CryptoSuite v2 protects **new one-to-one pairings** against recording. Groups, MLS
  and contacts paired earlier stay as they are.

#### 4.9.6 Rollout, readers first (as §6.7.5)

1. The relay accepts 1313-byte bundles beside 129.
2. Both apps read bundle v2, invitation v5 and handshake v3, and answer v3 as responders; nothing
   sends them.
3. Once builds that read are what contacts run (measured, as `J1634` measures), apps publish bundle
   v2 and send v5 and v3.
4. A switch then refuses classical-only invitations and handshakes for new pairings.

**Steps 2 and 3 are where a downgrade lives**, so two rules hold from step 3 on, not only at step 4.
An app that sends v3 never falls back to v2 or v1 for that attempt, whatever arrives or fails to
arrive (§6.5.9 already says the same of v2).

**The relay keeps a bundle v2 BESIDE the v1, not instead of it** (3 Oct 2026). Every build older than
the readers needs the v1 to invite at all, and group rosters carry 129-byte v1 bundles. So during
step 3 a classical invitation to a v2 publisher can be entirely legitimate -- an older sender -- and
the recipient cannot refuse it without stranding that sender. Downgrade resistance in step 3 is
therefore the SENDER's:

- **PQ capability is sticky per identity.** Once a sender has verified a bundle v2 for an identity,
  it never again sends that identity a classical invitation; a later fetch without a valid v2 fails
  the invitation rather than falling back. A relay that strips `bundleV2` can then only cause a
  failure, never a classical pairing, for anyone already seen with v2.
- **At step 4 the recipient refuses too:** a device that publishes v2 stops publishing v1 and refuses
  any invitation agreed against its classical prekey alone (v4 and older), as the switch for new
  pairings requires.

**Step 3b, in-person pairing (`J1901` 3b, 2.951).** A displayer that answers Handshake v3 draws six
random bytes for its 8-byte attempt nonce and sets the last two to
`SHA-256("mesh/pairing-answers-v3" ‖ displayedIdentity ‖ nonce[0..6])[0..2]`. The scanner starts v3
if and only if that marker verifies for the code's identity (asked of the core, never computed in an
app) or the identity is already marked post-quantum, and the flag is on. The nonce is hashed whole into
both rendezvous slots and the beacon, so stripping or forging the marker sends the scanner where
nobody listens: the attempt fails rather than pairing classically. An older displayer's random nonce
matches once in 65,536 attempts; that attempt times out and the next draws a new nonce. A trailing tag
on the code was rejected because a single texted code could simply be cut short (an external reviewer 74886299).

A first contact whose very first fetch is stripped is the residual window until step 4; it is the
same window any readers-first rollout has, and step 4 closes it.

Vectors first: bundle v2, invitation v5 and handshake v3, each from fixed randomness, and a test that
strips `ct` or swaps it for another valid one and is refused.

#### 4.9.7 Sender stamps: a first-contact request may carry two relay-signed stamps (`J2587`)

A pairing code (§4.2, versions 1 to 5) may end with a **trailer** carrying up to two stamps. Both are
optional; an older reader ignores trailing bytes (`J72a`), so a sender that attaches them costs no older
build anything, and a code without them is exactly what it was.

```
trailer   [0xB1][flags u8][born: t u64 BE | signature 64]      present when flags bit 0 (value 1) is set
                          [verified: t u64 BE | signature 64]  present when flags bit 1 (value 2) is set
```

The trailer starts at the byte after the last field the code's version defines (v1: the nonce; v2: the
name; v3: the sealed message; v4: the sender's bundle; v5: the ML-KEM ciphertext), and each present stamp
is 72 bytes, `born` first. A trailer that is shorter than its flags say, has a flag bit other than 1 or 2,
has no flag set, or does not begin with `0xB1` is **absent** (never an error, never a lost request).
Nothing authenticates the trailer as a whole and nothing needs to: each stamp is bound to the identity key
in the code by its hash, so a stamp pasted onto another profile's code verifies for nobody, and stripping
one only makes a sender look unverified.

**What each stamp is** (all signed by the relay's `TIME_SIGNING_KEY`, the key `RELAY_TIME_KEY` pins; the
signed message is UTF-8, `t` is decimal Unix seconds with no leading zeros, hex is lowercase):

| stamp | the hash | the relay signs | obtained from |
|---|---|---|---|
| `born` | `N = SHA-256("channel-age-v1|" ‖ identity)[0..16]` | `channel-time-v1|<hex N>|<t>` | `GET /v1/time` with `X-Channel-Time-Nonce: <hex N>`; `J1817` unchanged |
| `verified` | `A = SHA-256("channel-verified-v1|" ‖ identity)[0..16]` | `channel-verified-v1|<hex A>|<t>` | `POST /v1/verify/check` and `/v1/verify/renew` with `"attest": "<hex A>"`; the answer gains `"att": {"t", "sig"}` |

(`‖` is concatenation; each label ends with the byte `|`.) The two signed messages differ in their first label, so **a time stamp is
never a valid attestation and an attestation never a valid time stamp**, even over the same 32 hex digits
(relay test `verify-attest`, vector 39). `attest` is ignored unless exactly 32 lowercase hex digits. The
relay stores and logs nothing for either: it sees `attest` beside the value for the length of one call,
as it already sees the value and the slot. `verified` says only that this identity key completed a code
check at `t`; it names neither the value nor its kind.

**The reader** (`sender_stamps::read`, one function in the core, FFI `meshcore_pairing_read_stamps`, JNI
`pairingReadStamps`): decode the code; recompute `N` and `A` from the code's own identity key; verify
each signature strictly under the relay key. `born` counts when `t` is not more than 86,400 s ahead of the
reader's clock; `verified` counts when, in addition, `now - t` is at most 365 days (the proof's own life,
`verify.mts` `PROOF_TTL_MS`). A stamp that fails any of these is absent. The reader judges by its own
clock; `t` is shown, never trusted for anything but this window.

**The sender** (`meshcore_pairing_append_stamps`, `pairingAppendStamps`): the core places the trailer after
the code's last field and replaces any it had. Each profile holds its own `born` (fetched once, never
replaced, so an existing profile's age counts from its first stamp) and its newest `verified`
(exchanged for a fresh one when a month old, paid for by a still-live proof of a value on that profile's
card). Vector `vectors/39-sender-stamps.json` carries the hashes, both signed messages, and 18 trailers with
what each must read as; the Rust, Swift and Kotlin suites hold to it.

**Use** (`DECISIONS.md` `J2587`): the request card shows "Profile made N days ago" (or "age unknown") and
"Verified sender"; a per-profile switch, Only Verified Senders (off by default), sets aside a first-contact
request without a valid `verified` stamp from anyone who is not already a contact, unseen and unnotified.
It does not prove who a sender is: anyone who can receive a code at some number can be verified.

**Rollout, readers first.** Both apps read and judge the stamps and obtain their own; the switch that
puts them on an outgoing code (`SenderStamps.sendsStamps` on iOS, `SENDS_STAMPS` on Android) is off in the
build that adds the reader, and is flipped once that build is what contacts run.

## 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) · 8 device list (§5.8) · 9 profile sync (§5.10) · 10 device hello (§5.9) · 11 wipe (§5.11) · 12 reaction (§5.12) · 13 group message · 14 left · 15 thread ended · 16 removed · 17 roster request (all §5.2) · 18 attachment missing · 19 contact share request · 20 contact share disposition (both §5.13) · 21 location request (§5.14) · 22-25 call signalling (§5.15) · 26 thread history (§5.16) · 27 history attachment request · 28 history attachment unavailable (both §5.17) · 29 screen capture (`J1399`; no section yet) · 30 call request (§5.20) · 31 delete for everyone (§5.21) · 32 direct link signal (§5.22) · 33 update, retired (§5.23) · 34-37 formal groups (§5.24) · 38-43 formal groups on MLS (§5.25) · 44 group invitation · 45 invitation accepted · 46 invitation declined (all §5.2.7) · 47 group introduction (§5.2.8) · 48 view-once text (§5.27) · 49 message edit (§5.30) · 50 typing and chat-open (§5.31) · 51 poll create (§5.32) · 52 poll vote (§5.33) · 53 poll close (§5.34) · 59 start fresh (§5.39) · 60 command key grant (§5.40) |
| 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). |
| 10 | `messageId` | bstr 16 | *Optional.* The sender's own identifier for this message, stable across re-seals (§5.1a). |
| 11 | `groupCreator` | bstr 32 | *Optional.* Present on group payloads; the identity that established the thread (§5.2). |
| 12 | `groupRoster` | array | *Optional.* The full roster, when §5.2.4 requires it. |
| 13 | `groupName` | tstr | *Optional.* At most 48 bytes, cut on a character boundary (§5.2). |
| 14 | `groupRemoved` | bstr 32 | *Optional.* The identity removed, on `payloadType` 16 (§5.2). |
| 15 | `replyTo` | bstr 16 | *Optional.* The `messageId` (§5.1a) of the message this one answers (§5.19). Only beside key 10. |
| 16 | `aiAuthor` | uint | *Optional.* An AI wrote this message's text; the value is the colour its owner chose for it, `0xRRGGBB`, at most `0xFFFFFF` (§5.28). |
| 17 | `linkPreview` | map | *Optional.* The sender's preview of the first link in the text: title, summary, site and a small JPEG (§5.29). |
| 18 | `attachmentGroup` | bstr 16 | *Optional.* On chunk 0 of a group attachment (`payloadType` 6) only: the `groupId` (key 8's value) of the thread it was sent to (§5.6e). |
| 19 | `sentAtMillis` | uint | *Optional.* 0 to 999: the milliseconds of the send time whose whole seconds are key 3, so a receiver can order messages sent within one second (§5.1b). Never on `payloadType` 6. |
| 22 | `intro` | uint | *Optional.* `1` on chunk 0 of a video attachment (`payloadType` 6, media type 4) only: the sender's statement that this is their intro video (§5.6f). Absent means not. Key 21 is unassigned. |
| 23 | `command` | bstr 24 | *Optional.* A command key's counter (u64 BE) and 16-byte tag over this message's text and its recipient, written only by a device holding a key an AI's profile granted it, on `payloadType` 1 and 13 only (§5.40). Absent otherwise. |
| 20 | `urgent` | bool | *Optional.* The CBOR simple value true, written only when the sender marked this message urgent (§5.1c); absent otherwise. On `payloadType` 1, 13 and 48 only. |
| 24 | `forwarded` | bool | *Optional.* The CBOR simple value true, written only when the sender marks this message as a copy of one written for somebody else and sent on (§5.1e); absent otherwise. On `payloadType` 1 and 13, and on chunk 0 of an attachment (`payloadType` 6), only. |

#### 5.1a `messageId`, and why it is a map key rather than a body prefix

A sender re-seals an unconfirmed message after a relaunch with fresh nonces (`J291`), so the same
message reaches a receiver as a different envelope and the envelope-hash guard cannot see it.
`J294` fixed that for pictures by deriving the attachment id from the message id; **text had no
inner identifier at all**, so a receiver could not tell *hello* re-sent from *hello* typed twice.
`messageId` is that identifier: 16 bytes minted by the sender when the message is created and
unchanged by every later seal. A receiver that has already filed a message with the same
`(senderIdentity, messageId)` drops the repeat.

It is also what a **reaction** (§5.12) names, and it must be, because each side's own local
identifier for a message is its own and never travels.

**Why a map key.** An unknown map key is skipped by a v1 decoder (§1.2), so a sender that includes
this reaches an older receiver unharmed. A 16-byte prefix on the `body` of a text payload would
instead be rendered as part of the message by any build that did not expect it — a silent,
user-visible corruption, which is the failure mode this protocol is written to avoid.

**Not a secret and not a nonce.** It is inside the AEAD like everything else in the map, it is
never reused for a different message, and nothing about it is derived from the message's content —
a receiver must not treat two messages as equal because their ids match without also checking the
sender.

#### 5.1b `sentAtMillis` (key 19): ordering messages sent within one second (`J2512`, 7 October 2026)

**Problem.** Key 3, `sentAt`, is the sender's wall clock in whole seconds, and a receiver files a
thread by it. Two messages written in the same second tie, and a tie falls back to arrival order --
which is not send order, because routes and mailbox fetches reorder (`J522`). Five messages sent by
an AI in 0.17 seconds arrived and were shown as 4, 3, 2, 1, 5.

**The field.** Payload key 19, `sentAtMillis`, uint 0 to 999: the millisecond part of the same
instant whose whole seconds are key 3. Inside the sealed payload, authenticated like every other key.

- **Sender.** Takes ONE stamp in milliseconds since 1970 and writes `stamp / 1000` as key 3 and
  `stamp % 1000` as key 19, so the two keys always describe one instant. Each device keeps its last
  stamp and never issues one less than or equal to it: `stamp = max(now_ms, last + 1)`. A clock
  stepped backwards therefore cannot reorder that device's own messages, and a burst faster than the
  clock's resolution still gets distinct stamps; the cost is that a stamp may run ahead of the clock
  by one millisecond per message in a burst. Written on messages a receiver files in a thread
  (`payloadType` 1, 13 and 48); a sender MUST NOT write it on `payloadType` 6, whose chunks must
  stay identical in size (§5.6), and `Payload::encode` refuses it there and above 999.
- **Receiver.** Two inbound messages that BOTH carry key 19 and whose key 3 is equal are ordered by
  key 19, lower first; in every other case -- either one lacks it, or their seconds differ -- the
  rule is exactly what it was without the field. A receiver that clamps a claimed `sentAt` because it
  is too far in the future (`J522`) ignores key 19 for that message too. A value above 999 is
  malformed and the payload is refused, as an over-wide key 16 is.
- **Older builds.** Skip key 19 as an unknown key (§1.2, present in every decoder since the first),
  and keep ordering by key 3 alone -- what they do today. So the field is safe to send to any build,
  and needs no capability; the sending apps hold it behind a switch until the reading build is
  installed (`CLAUDE.md`: readers first).

**Why milliseconds and not a sequence.** Key 2, `sequence`, already exists and cannot do this: the
core keeps one counter per process for every payload type (receipts and chunks included), and it
starts again from 1 at each launch. A new per-sender sequence could be made monotonic, but it would
order only what ONE device sent -- a profile on several devices (§5.8) has several counters that
cannot be compared -- and it must be persisted per contact through relaunch, reinstall and restore
from a backup, each of which can rewind it. A wall-clock stamp needs no stored state, compares
across the sender's devices exactly as well as key 3 already does, and the `max(now, last + 1)` rule
gives it a counter's one property that matters inside a device: it never goes backwards. It tells the
recipient nothing new beyond the sub-second part of a send time it already sees to the second.

#### 5.1c `urgent` (key 20): the sender's urgent mark (`J2651a`, 11 October 2026)

**The field.** Payload key 20, `urgent`, the CBOR boolean true, written only when the sender marked
this message urgent and omitted otherwise. Inside the sealed payload, authenticated like every other
key. A sender writes it on `payloadType` 1, 13 and 48 (the messages a person reads) and
`Payload::encode` refuses it elsewhere; a decoder reads it on any type and the receiving app decides it
means something only on a message. The value false is read as not urgent; any value that is not a
boolean is malformed and the whole payload is refused, as an over-wide key 16 is.

- **Receiver.** The core only reports it. The app decides what it is worth: a receiver whose own switch
  lets urgent messages through quiet hours shows the alert; one whose switch is off treats the message
  as any other. The sender's mark is a request, never an override of the receiver's settings.
- **Never a deposit flag.** The relay's `urgent`/`ring` deposit flag is the VoIP push that rings a CALL
  OFFER (`J1055`, `J1066`); a message that set it would ring as a call, and the platform kills a
  delivery path that does so without presenting one. The mark lives only inside the seal, and a deposit
  for a message never sets that flag. The relay therefore learns nothing from it.
- **Older builds.** Skip key 20 as an unknown key (§1.2, present in every decoder since the first) and
  show an ordinary message. Safe to send to any build; needs no capability.
- **Capability `urg` (`J3265`, 22 October 2026).** A receiver tells ONE contact, in the capability list of the card it
  sends that contact, that it will honour an urgent mark from them: its master switch is on, that contact is
  allowed, and the operating system permits a break-through. A sender offers Send as Urgent only toward a card that
  carries `urg`, checked when the message is sent; a missing, older or tagless card offers nothing. The tag is one
  more string in the existing `caps` list (no layout change; a reader that does not know it keeps it, §1.2), and it is
  said per contact, so it is not in the list every build advertises. A receiver treats a mark from a contact it does not
  allow as an ordinary message regardless of what the sender saw. Android does not write or read the mark yet and never
  says `urg`.

`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.1e `forwarded` (key 24): the sender's forwarded mark (`J2876`, 15 October 2026)

**The field.** Payload key 24, `forwarded`, the CBOR boolean true, written only when the sender
forwarded this message: the words or the file were written for somebody else and this copy sends them
on. Omitted otherwise. Inside the sealed payload, authenticated like every other key. It lets the
receiving app draw *Forwarded* so nobody reads forwarded words as words written to them.

- **One bit, nothing else.** The mark names no original sender, no time and no conversation, and the
  copy carries nothing of the original (`DECISIONS.md` J228). A receiver learns only that it is a
  forward, never from whom. The mark rides in the sealed message like every key; the relay learns
  nothing from it and it is never a deposit flag.
- **Where it is written.** A sender writes it on `payloadType` 1 and 13 (a text, in a conversation or
  as a group copy) and on chunk 0 of an attachment (`payloadType` 6): the file's other chunks stay
  byte-identical to what every earlier build sealed, so a streamed send's retry identity (§5.6a) is
  untouched and the first chunk stays in its padding bucket. `Payload::encode` refuses it on any other
  type and the core's public seal entry points refuse it on any chunk but chunk 0.
- **Where it is believed.** A decoder reads key 24 on any type (§1.2: it must not refuse a key it is
  required to ignore), and the core reports it to the app only on a text and on chunk 0 of an
  attachment (`Reception::Delivered { forwarded }`, `out_forwarded`), so the one place a receiver
  reads it is the one place a sender writes it. A value that is not a boolean is malformed and the
  whole payload is refused, as for key 20.
- **An attachment keeps the mark with its set.** A receiver notes it from chunk 0 and applies it
  to the message it files when the set is whole, whatever order the chunks arrive in. A caption
  that travels as a text beside the chunks carries the same mark, and either one marks the filed
  message.
- **Older builds.** Skip key 24 as an unknown key (§1.2, present in every decoder since the first) and
  show an ordinary message; a build reading an older sender's message reads it as not forwarded. Safe
  to send to any build; needs no capability. `vectors/53-forwarded-payload.json` pins the bytes, the
  older build's reading, an unknown key beside it, the false and the integer forms, and chunk 0.
- **Not carried.** A forward into an MLS group or a formal group's submission is a different message
  (an MLS application message, a submission to the admin) with its own wire; those carry no key 24.

### 5.1f `hopBudget` (key 25): how many nearby devices carried a message (`J3509`, 27 October 2026)

**The field.** Payload key 25, `hopBudget`, an unsigned integer `1..=MAX_ACCEPTED_HOP_LIMIT` (§2.2): the
`hopLimit` the sender put on the envelope when it sealed it, written only when that value is above zero
(a sender never writes a value above `MAX_ACCEPTED_HOP_LIMIT`; it writes the maximum). Omitted when the
sender did not permit carrying, and by every build before `J3509`. It follows key 24 in canonical order and
is inside the sealed payload, so it is authenticated like every other key and is never a deposit flag.

- **What a receiver computes.** `hopsSpent = hopBudget - hopLimit of the envelope as it arrived`, saturating
  at 0 and never above `hopBudget`. Each carrier spends exactly one hop (§7.7a: the hop limit is first
  clamped to `MAX_ACCEPTED_HOP_LIMIT`, then one is spent), so it counts any number of carriers: 0 is a message
  that arrived direct, 1 one carried once, and so on. With no `hopBudget` the count is **unknown**, which a
  receiver MUST NOT show as 0 (an older sender, or one that forbade carrying).
- **Where it is written and read.** Written on every `payloadType` the sender seals with a limit above zero.
  A decoder reads it on any type (§1.2). The core reports it as `Reception::Delivered { hops_spent }`,
  `out_hops_spent` (255 means unknown) and, in the JNI bridge, byte 2 of `outForwarded`. A value that is not
  an unsigned integer in `1..=MAX_ACCEPTED_HOP_LIMIT` (0, 9 or more, a negative, a boolean, a byte string)
  is malformed and the whole payload is refused.
- **It is a label, not evidence.** The arriving `hopLimit` is outside the AEAD (§2.2) and a carrier may
  change it, so a hostile carrier can make the count read higher or lower, and can make a carried message
  read as direct. It cannot forge content, a sender, or a time: those stay authenticated. An app uses the
  count to draw how a message travelled and for nothing that trusts it. The count names no carrier.
- **Older builds.** An older decoder skips key 25 as an unknown key (§1.2) and shows an ordinary message;
  a build reading an older sender's message sees no budget. Safe to send to any build; needs no capability.
  `vectors/63-hop-budget-payload.json` pins the bytes, the older reading, an unknown key beside it, the
  refusals and the arithmetic.
- **Streamed chunks.** A later chunk of a streamed attachment must re-seal byte-identical (§5.6a), so a
  sender uses the same hop limit for every chunk of one message.

### 5.1d Receipt bodies and the batched read receipt (payload types 2 and 3, `J2528` R3, 11 October 2026)

A delivery receipt (type 2) and a read receipt (type 3) carry a fixed-layout body, not CBOR. It is
**not** in `mesh-core-rs`: it is one pure codec in `channel-algo` (`delivery_state`, ops 10 decode, 11 encode and
12 batch, `J3317` item 7b), called by `Receipt.swift` and `Receipt.kt` through the same wrapper on each platform, and
held to the same bytes by `vectors/50-batched-read-receipt.json` and by `vectors/algo/receipt.json` (written by an
independent Python reference). The id it names, `SHA-256(envelope)[0..16]`, is still computed in the apps. A receipt
time past one day ahead of the receiver's clock is clamped to now plus one day, as an unsigned value on both platforms.

```
body = id(16) x k | seconds(8, big-endian) | route(1, optional)        1 <= k <= 8
```

- Length is `16k + 8` or `16k + 9`. The two families never overlap (their lengths differ mod 16), so the
  length alone says how many ids there are and whether a route byte follows. For k = 1 these are exactly the
  24 and 25 bytes every build already reads, so an old body is unchanged.
- A body of any other length, k above 8, or the same id twice is refused whole.
- `seconds` is the moment it happened on the reader's device; a time more than a day ahead is clamped to a
  day ahead, as for a single id. One timestamp and one route cover every id in the body.
- The route byte is the table in §5.16b (1 Bluetooth, 2 Wi-Fi Direct, 3 local network, 4 Internet, 5 SMS bridge,
  6 direct IPv6); an unknown byte names no route and the receipt still counts.
- A reader applies the receipt to each id it names. A body naming ids it does not hold changes nothing for those.

**Capability `rcb`**: a build that decodes `k > 1` says so on its card. A sender puts more than one id in a
**read** receipt (up to 8, one body per writer and route) only to a card that carries `rcb`; to any other card
it sends one read receipt per message, as before. A **delivery** receipt always names one id. An older build
refuses a `k > 1` body at decode, which is why the sender waits for the tag: opening a thread with 20 unread
costs 3 deposits to a card with `rcb` and 20 to one without.

### 5.6c Size padding for attachments (`J1455`, 29 September 2026) -- NOT ADOPTED

**Decided by the owner the same day: not built.** The existing rule -- every chunk full length, the
last one zero-padded -- already means an observer learns an attachment's length only to within one
chunk (16,000 bytes), so nobody can identify a particular document from its size without knowing it
to the byte. PADMÉ would widen that window for large files at up to ~12% extra data; the owner judged
the current window sufficient (*"the mission has been accomplished"*). Kept below as the design to
reach for if that judgement ever changes. The caveat stands: a matching chunk count is consistent with,
not proof of, a suspected transfer.


**Threat.** Somebody watching the network (a relay operator, a Wi-Fi owner, a state) holds a
particular file -- a leaked document -- and knows its exact byte length. They must not be able to tell
from what CHANNEL puts on the wire whether that file was sent. (The owner, 29 Sep 2026: *"do whatever
is necessary to make sure no one can identify a particular leaked document based on packet sizes."*)

**What an observer sees today.** An attachment's real length (`total_bytes`) travels inside the
seal. On the wire there are only `n` envelopes of one fixed size, so the observer learns
`n = ceil(total_bytes / CHUNK)` -- the length to within one chunk. For a file of a few megabytes, that
narrows the candidates to files within 16,000 bytes of each other, which is enough to confirm a known
document.

**The rule: pad the chunk count to PADMÉ.** A sender sends
`n_padded = ceil(padme(total_bytes) / CHUNK)` chunks, where `padme` is `mesh-core-rs`'s existing
function (Nikitin et al., PETS 2019). The first `ceil(total_bytes / CHUNK)` chunks carry the file
exactly as today. The rest are **padding chunks**: full-size, with the same fields, and `data` all zero.
Every chunk keeps the fixed wire size, so the observer sees only `n_padded`. Chunks carry
`total_chunks = n_padded`.

- **What that buys.** PADMÉ rounds a length to a bucket about 1/2^(S) of its size (S grows slowly
  with the length), so a file hides among every file whose padded size lands in the same bucket.
  The overhead is at most about 12% and far less for large files; the information leaked about the
  length falls from ~log2(length) bits to ~log2(log2(length)). Below 16,000 × 16 bytes, PADMÉ's
  bucket is smaller than one chunk and nothing changes: a small file already hides among every file
  of the same chunk count.
- **Never worse than today.** Every chunk stays full length, so no size leaks through a short last
  chunk. The observer's anonymity set is `max(today's, PADMÉ's)` at every size.
- **Receiver.** Accepts `total_chunks` in `ceil(total_bytes/CHUNK) ..= ceil(padme(total_bytes)/CHUNK)`
  and **refuses anything above that bound** (a peer cannot make a phone wait for, or store, more than
  12% extra). A chunk whose `index >= ceil(total_bytes/CHUNK)` must be all zero; it is acknowledged and
  discarded, never stored. The attachment is complete when its data chunks are complete; padding
  chunks are not waited for and are not asked for as missing.
- **Capability `padm`** on the card says the build reads this. A sender pads only for a peer that
  advertises it, so an older build is never sent a chunk count it refuses. Readers ship first.
- **Every other path that exposes a length pads the same way**: a bundled relay upload (its object is
  the sum of padded envelopes), a large Welcome or group photo sealed once and uploaded (§5.23a,
  §5.25.9 -- pad the sealed object's plaintext to `padme`, with the true length inside the sealed
  reference so the reader truncates), and streamed sends (§5.6a) likewise.
- **What this does not hide.** When a transfer happens, and between whom, is visible to anyone who
  can see the traffic itself. Size padding answers "which file", not "whether".

### 5.6d Chunk-zero acknowledgement (`J1771`, 1 October 2026)

**Problem.** A radio write proves only that some phone took the bytes (§4.7a covers Bluetooth's
own ack, and only for a peer bound to the contact). A message whose radio claim is not followed by
a delivery receipt within the receipt window (8 s) is sent again by the Internet (`J1618`). An
attachment has no such rule for its first slice, and the receiver cannot repair it either: the
missing-list (type 18) is sent only for an attachment the receiver knows exists, and with the
ratchet on (§4.5b) no later slice opens until chunk 0 has. A chunk 0 lost on a radio therefore
leaves the receiver holding slices it cannot open and nothing that will ever ask for the one it
lacks.

**The acknowledgement.** An ordinary delivery receipt (payload type 2, the 24-byte receipt body in §5.1 key 7:
16 bytes of id, 8 of big-endian seconds) whose id is

```
ackID = SHA-256( "CHANNEL chunk-zero ack v1" (UTF-8, 25 bytes) || chunk-0 envelope )[0..16]
```

over the envelope exactly as transmitted. A message's own receipt id is `SHA-256(envelope)[0..16]`,
so the label keeps the two from ever naming the same thing.

**Receiver.** When an attachment chunk (type 6) opens and it is the FIRST slice of that
attachment id this process has opened, and it arrived by Bluetooth, Wi-Fi Aware or the shared LAN
(or by a route the transport cannot name), and the sender's contact card carries the capability
`c0a`, the receiver sends that receipt with `ackID` computed over the envelope it received. It is
sent as any receipt is (it follows the message: the radio first, the mailbox when no radio takes
it). Under the ratchet the first slice to open is chunk 0. Without it, a first slice that is not
chunk 0 yields an id the sender does not hold, which the sender treats as no acknowledgement --
correct, since chunk 0 has not arrived.

**Sender.** When chunk 0 of a pass is taken by a radio rung (not a `J1804f` catch-up, not a
`J252` rescue) and the recipient's card carries `c0a`, the
sender holds chunk 0's envelope and waits the receipt window. If by then no receipt naming `ackID`
has arrived, the attachment has no delivery receipt, and the message has not been rescued, it
sends the SAME envelope once more by the contact's own route (a direct link, else the mailbox).
When the mailbox may NOT carry this attachment -- in particular when the contact's card currently
refuses the Internet, which also refuses the direct link -- the watch is still armed, and the same
envelope goes once more by the radio peers chunk 0 was offered (`J2501`, 6 October 2026: without this,
a chunk 0 a radio claimed and lost to a contact with the Internet off was never sent again).
At most once per pass; the watch is in memory and a restart inside the window drops it. The
"photos direct only" setting is asked again when the window ends. A resend does not move a
message's route mark away from a radio -- that mark is what the `J252` rescue keys on to resend
every slice a radio may have lost -- it is added to the route trail instead. A receipt naming an
`ackID` is never applied to a message, matched or late.

**Readers first.** A build without `c0a` never receives an acknowledgement (the receiver gates on
the sender's card) and never causes a resend (the sender gates on the receiver's card). An older
sender that did receive one would find no message with that id and ignore it.

### 5.6e Which group an attachment belongs to (`attachmentGroup`, payload key 18, `J2470`, 6 October 2026)

**Problem.** A group attachment is sealed pairwise to each member, like every group message (§5.2:
there is no group key), as ordinary attachment chunks (`payloadType` 6). Nothing in a chunk said
which group it was for. The group announcement is a text envelope (type 13) sent only when there
is a caption, so a caption-less photo -- the usual case -- reached each member as bare chunks: a
sender who is also a direct contact had it filed in the one-to-one chat, and one met only through
groups had it filed in whichever shared group came first.

**The field.** Payload key 18, `attachmentGroup`, bstr of exactly 16 bytes: the `groupId` -- the
same 16 bytes key 8 carries on the thread's own messages -- of the thread the attachment was sent
to. It is inside the sealed payload, authenticated by the AEAD like every other key, and never on
the wire in clear.

- **Sender.** Written on **chunk 0 only** of an attachment sent to a group thread (informal or
  formal), on both the held and the streamed path; every later chunk is byte-identical to what a
  build without this field seals. A sender MUST NOT write key 18 on any other `payloadType`
  (`Payload::encode` refuses it) or on a chunk other than 0 (`Node` refuses it). It costs nothing
  an observer can count: the padded size of chunk 0 is the same bucket with or without it
  (measured in `node.rs`), and on the streamed path the §5.6a nonce is derived from the complete
  plaintext, which includes it.
- **Receiver.** The core reports key 18 only when the payload is an attachment chunk whose index
  is 0; on any other payload, or a later chunk, it is decoded for length and ignored. The app
  treats it as a claim: it files the whole set (every slice of that attachment id) in the named
  thread only when this device holds that thread, it is not an MLS thread (§5.25 posts never
  travel pairwise), its charter accepts the sender if it is formal (§5.24), and it lists the
  sender as a participant if it is informal. Otherwise the set is routed exactly as without the
  field. A receiver never creates a thread because of key 18.
- **Order of arrival.** Slices may arrive before chunk 0. They are filed where routing put them
  without the field; when chunk 0 names a thread, the slices of that attachment already held for
  this sender elsewhere are moved to it. Later slices follow the set: the receiver remembers where
  each attachment id is being assembled, and after a restart reads it from where its pieces are
  stored.

**Why key 18 and not key 8.** Every build's decoder refuses key 8 (and keys 9, 11-14) on a
`payloadType` that is not a group type (`check_group`: "group fields on a non-group payloadType").
Chunk 0 carrying key 8 would be refused by every receiver -- and with it the whole attachment.

**Readers first, and no gate.** Every decoder since the first (1 September 2026) skips an unknown
payload key (§1.2), so an older receiver reads chunk 0 with key 18 exactly as one without it and
files the attachment where it always did. This was measured, not assumed: the decoder as it stood
at the change's merge base decoded a chunk 0 with key 18 spliced in and returned it intact, and
refused the same id under key 8. So the writer needs no capability gate.

Vector: `vectors/30-attachment-group-payload.json`.

### 5.6f The intro video (`intro`, payload key 22, `J2646b`, 11 October 2026)

**What it is.** An ordinary video attachment (media type 4, §5.6) with one authenticated mark: the
sender's statement that it is a live recording of themselves, at most 10 seconds, sent with a first
contact request (`J2646`). The mark lives in the sealed payload, so only the holder of the pairwise
key can write it; it proves who SAYS it, never that the video shows who it claims.

**The field.** Payload key 22, `intro`, uint, the value `1` or absent. Any other value is malformed
(one fact, one encoding). Key 20 is not assigned and stays skipped as unknown (vector 31 uses it for
that).

- **Sender.** Written on **chunk 0 only** of a video attachment, held or streamed. `Payload::encode`
  refuses it on any `payloadType` but 6; `Node::seal_streamed_attachment_chunk_in_group` refuses it
  on a media type but 4. Every later chunk is byte-identical to what a build without the field seals.
  On the streamed path the §5.6a nonce is derived from the complete plaintext, which includes it.
- **Receiver.** The core reports `intro` only when the payload is chunk 0 of a media-type-4
  attachment; anywhere else it is decoded and ignored. The app treats it as a claim and applies its
  own bounds (length before opening, size after, duration) -- those are app rules, not the core's.

**Readers first, no gate.** Every decoder since the first skips an unknown payload key (§1.2), so a
build that does not know key 22 reads the chunk as an ordinary video. No capability is needed.

Vector: `vectors/48-intro-video-payload.json`.

### 5.6a Sealing an attachment too large to hold — IMPLEMENTED (`Node::seal_streamed_attachment_chunk`; both apps send large videos this way)

> **Built, and called by both apps** (`J1554`). It was written before the code, as this project's
> order requires (Assignment 01: the protocol leads the implementation).
>
> **Revised 14 September 2026 after an adversarial review**, which found the first draft wrong in
> two ways and improvable in a third. The review and what it changed are recorded at the end, because
> a design whose corrections are invisible invites the same mistakes back.

#### The problem, stated exactly

A sender can already slice an attachment off disk without holding it — `J663`'s `sliceForChunk`
reads chunk `i` at its offset and pads `J605`'s short tail so the object's true length does not
leak. That half is built and tested. What stops it being used is **sealing**:

1. Every envelope's nonce is **12 random bytes** (`Node::next_nonce`, `getrandom`), so a re-seal
   produces different ciphertext.
2. `J255` exists because of (1): a message is sealed once per (message, epoch) and the whole sealing
   is **cached**, so a retry puts byte-identical envelopes on the air. `J581`'s
   skip-what-already-landed rests on that — indices are only stable while the bytes are.
3. A 3 GB sealing cannot be cached. So a streamed send needs a nonce it can **recompute** rather
   than remember.

#### What a recomputable nonce must satisfy

The requirement is not "deterministic". It is **never the same (key, nonce) pair over two different
plaintexts**. The AEAD here is **ChaCha20-Poly1305** (`mesh-core-rs/src/aead.rs`), where reuse gives
an attacker the XOR of two plaintexts *and* the Poly1305 key for that (key, nonce), hence forgeries.

- **Unique per chunk within a message** — the index supplies this.
- **Unique per message within a key** — the key is per PAIRING GENERATION (`messageKey`, §4.5), not per epoch; `messageId` (§5.1a) is
  `SHA-256(uuid)[0..16]`, locally generated.
- **Stable across a retry of the same bytes** — the whole point.
- **Never reused across two different plaintexts** — the one the others do not give, and the one
  that decides the design.

#### The hazard that decides it, and why a whole-file hash does not fix it

*The same (messageId, index) sealed twice with different plaintext.* It is not hypothetical: a
streamed send reads a file it does not own — that is what streaming is for — and a file can change
under a message that has not finished sending. (A small attachment is copied into the app's own
store first, `attachmentsDirectory`, and is immutable once filed. The multi-gigabyte case is
precisely the one where it is not.)

The obvious answer — hash the whole file once, bind the nonce to that hash — **does not work**, and
the reason is a time-of-check/time-of-use gap the first draft of this section missed. The hash pass
happens before the first chunk goes out; the reads happen later. If the file changes in between, the
sender derives `nonce_i` from the *old* hash while sealing the *new* bytes. A later retry of the
original bytes under the same stored hash is then the exact reuse this section exists to prevent.
Deriving a per-attachment key from the whole-file hash instead has the same gap: same key, same
counter, different plaintext.

#### The derivation

**Bind each nonce to the complete AEAD input it is about to seal — the AAD and the plaintext — and
to nothing that can go stale.**

```
nonce_key = HKDF-Expand(prk = sending_key, info = "mesh/att-nonce-key-v1", L = 32)
nonce_i   = HKDF-Expand(prk = nonce_key,
                        info = "mesh/att-nonce-v2" || messageId(16) || u32_be(i)
                               || SHA-256(aad_i(47) || plaintext_i),
                        L = 12)
```

where **`aad_i` is the envelope's 47-byte AAD (§3.1) and `plaintext_i` is the ENCODED PAYLOAD** — the
exact two byte strings handed to the AEAD, not a file, not a promise about a file, **and not the raw
slice either**. The AAD is fixed-length, so `aad ‖ plaintext` is unambiguous.

> **v2, 1 October 2026 (`J1833`, from an outside review, finding M1).** v1 bound `SHA-256(plaintext)`
> under the label `mesh/att-nonce-v1`. The epoch reaches the AAD — the destination token and the
> creation bucket — without reaching the plaintext, and a later chunk's packet id is derived, so a
> re-seal with the same `sentAt` in another epoch sealed **one (key, nonce) under two AADs**. Two
> Poly1305 tags under one one-time key give that key away, and with it forgeries of the chunk. The
> review demonstrated equal nonces and two different tags; no shipped caller is known to have passed
> an old clock into a new epoch, because both apps read a fresh clock for every seal.
>
> **Not a wire change.** The nonce travels in the envelope (§3.1) and a receiver opens with the one
> it is given; nothing recomputes it on the receiving side. So the sender changed the derivation with
> no capability gate, and every receiver, old or new, opens what it seals.

> **Corrected 16 September 2026, after an adversarial review found a nonce reuse.** This sentence
> already said "the bytes that `seal` is about to receive", and it was right; the first
> implementation still fed it the raw slice. The plaintext is strictly larger than the slice — it
> also carries `sentAt` (§5.1 key 3, the sender's wall clock) and `sequence`. So two seals of one
> slice a second apart produced **the same nonce over two different plaintexts**, which under
> ChaCha20-Poly1305 gives up the XOR of them and the Poly1305 key for that pair.
>
> Nothing failed. Every test passed, because each happened to pass the same clock value twice, while
> a real sender reads a clock and would have moved it on every attempt.
>
> The lesson is why the wording is now explicit rather than merely correct: *"the bytes `seal`
> receives"* and *"the chunk plaintext"* read as the same thing to somebody who has the slice in
> front of them. **Bind the nonce to the complete input to the AEAD.** Then every field that can
> distinguish two plaintexts is inside the digest by construction, including fields nobody has
> thought of yet, rather than by a list somebody has to keep complete.

Four properties follow, and the third is the one the whole-file version could not give:

- **Retry-identical while the AEAD input is.** The same plaintext under the same AAD hashes the
  same, so `nonce_i` reproduces. **What moves it is any byte of either**: the slice, `sentAt`,
  `sequence` and the chunk's metadata in the plaintext; the packet id, the destination token and the
  creation bucket (both from the epoch) and the lengths in the AAD. So a retry in the same epoch with
  the same recorded `sentAt` and `sequence` reproduces the whole envelope, and a re-seal in a later
  epoch is a different envelope with a different nonce — which is correct: it is a different AEAD
  input, and sharing a nonce with the first would be the reuse above. Two inputs need the sender's
  help to stay put:
  - **`packetId`** is in the AAD (§3.1), so a fresh one per attempt changes the envelope and, from v2,
    the nonce with it. Settled by §5.6b, which derives it.
  - **`sequence`** is inside the payload (§5.1), so the same applies.

  On the ordinary path both are settled by `J255`, which caches the finished sealing. A streamed
  send has no cache, so **a sender MUST reuse the same `packetId` and `sequence` for a given
  `(messageId, index)` across every attempt**, or `J581`'s skip-what-already-landed compares against
  a seal identity that has moved and re-sends everything. `mesh-core-rs` takes both as parameters
  rather than drawing them, and
  `a_streamed_chunk_is_only_retry_identical_while_packet_id_AND_sequence_HOLD` demonstrates each
  half rather than describing it.

  `packetId` is answered by **§5.6b below**. `sequence` is not derived: it is the sender's own
  counter, and a streamed sender MUST record the value it used for a chunk and reuse it.
- **Key-separated.** The sending key is the AEAD key; a nonce is derived under a *different* key
  expanded from it, so one key material is not doing two jobs.
- **No window.** The hash is of the bytes in hand. There is no interval during which the input could
  become untrue, because the input is not a claim about anything else.
- **Cheaper.** No whole-file pass before the first byte leaves, which also removes a second reason a
  3 GB send would appear to stall before starting (`J762`).

Two rules an implementation must not omit:

- **`J255`'s cache must not be consulted for a streamed send.** Half a sealing from the cache and
  half recomputed is two nonce schemes in one set, which is how a reuse is produced.
- **Every field in `info` is fixed-length.** `messageId` is 16 bytes, the index 4, the digest 32. If
  any becomes variable, it needs a length prefix or the concatenation becomes ambiguous.
- **The labels carry the protocol's name, never the product's.** `mesh/…`, matching
  `tokens.rs`, whose own comment records why: the labels were `OfflineMesh/…` until the product was
  renamed on 2 September 2026, which silently changed every derived key and every token and was
  caught only because the conformance vectors failed. A label embedding the brand makes any future
  rebrand a silent wire-format break. The first draft of this section used `chan-att-…`; it is
  corrected here rather than discovered later.

#### The bound, stated rather than implied

The nonce is 96 bits (`envelope.rs: NONCE_LEN = 12`). Collision becomes non-negligible around 2^48
chunks under one pairing-generation key — hundreds of petabytes at this chunk size, so it does not
bind in practice, but it is the same bound random nonces have and this construction does not improve
it. A deterministic scheme also cannot resample its way out of a collision the way a random one
theoretically could. Worth knowing before anyone proposes raising the ceiling again.

#### What the review changed, and what it did not

Reviewed adversarially on 14 September 2026 against this section, `aead.rs` and `envelope.rs`:

- **The primitive was named wrong.** The draft reasoned about AES-GCM. It is ChaCha20-Poly1305, and
  the consequence of reuse differs — corrected above, in a section about nonce reuse, where naming
  the wrong cipher is not cosmetic.
- **The whole-file hash did not close its own hazard.** The time-of-check gap above. This is the
  finding that changed the design rather than the prose.
- **Key separation.** The draft used the sending key directly as the HKDF PRK for a nonce used under
  that same key. Now a derived `nonce_key`.
- **Not taken:** a suggestion to derive a per-attachment content key from the whole-file hash and use
  a counter nonce. It is a good construction and it inherits the same time-of-check gap, which is the
  problem being solved; per-chunk binding does not.

#### Status, 1 October 2026

- `vectors/07-streamed-attachment-nonce.json` is **generated**, never transcribed, and carries six
  cases plus two separation checks. The cases are the argument: the same bytes under the same AAD
  reproduce the nonce; **one edited bit moves it**; the same bytes at a different index differ; **the
  same bytes one epoch later — a different AAD — differ** (`J1833`); and a different key or a
  different message differ, so an implementation that dropped a field from `info` fails at the vector
  rather than in the field. It holds v2 only: no receiver ever recomputes a nonce, so v1 has nothing
  left to be read by.
- `mesh_core_rs::attachment::streamed_chunk_nonce` implements it and
  `conformance::streamed_attachment_nonce_matches_the_vector` holds it to the file;
  `a_streamed_chunk_resealed_in_another_epoch_never_reuses_its_nonce` holds the caller to handing it
  the AAD it really seals under.
- **Both apps call it** through `Node::seal_streamed_attachment_chunk` for large attachments
  (`J1554`); the send path seals one chunk at a time from disk and bypasses `J255`'s cache, so one
  set never mixes two nonce schemes.


### 5.6b — The packet id for a streamed chunk

**Status: built and in use.** Implemented in `mesh-core-rs` (`attachment::streamed_chunk_packet_id`),
held against `vectors/08-streamed-attachment-packet-id.json`, which is generated, and called by the node
when it seals a streamed chunk (`seal_streamed_attachment_chunk_in_group`), which both apps use. Chunk 0
of a ratcheted attachment is the exception: it takes a fresh id per seal (`J1559` §4.5b).

#### Why a derivation is needed

§5.6a makes the *nonce* recomputable. It does not make the envelope recomputable, because
`packetId` is in the AAD (§3.1) and a sender drawing a fresh one per attempt produces different
ciphertext every time with a perfectly stable nonce. `J581`'s skip-what-already-landed then compares
against a seal identity that has moved and re-sends everything — a large transfer that never
converges, with nothing failing anywhere to say so.

On the ordinary path `J255` settles this by caching the finished sealing. A streamed send has no
cache, by design.

#### Why it is derived from a secret

The obvious answer is a public derivation from `(messageId, index)`. **It leaks.** A packet id
travels in clear in the envelope header; if it were a published function of a message id and a
counter, anybody who can see two envelopes could tell they belong to one attachment and count its
slices. Random ids give that away to nobody, and a replacement has to keep the property.

Expanding under a key only the two ends hold keeps the ids indistinguishable from random for
everybody else while the sender can reproduce them exactly.

```
packet_key   = HKDF-Expand(sending_key, "mesh/att-packet-key-v1", 32)
packetId_i   = HKDF-Expand(packet_key, "mesh/att-packet-v1" ‖ messageId(16) ‖ be32(index), 16)
```

Every field fixed-length, so the concatenation is unambiguous. A variable-length field added here
would need a length prefix.

#### Properties

- **Retry-identical.** The same `(sending_key, messageId, index)` reproduces the id, which is what
  makes the whole envelope reproduce once §5.6a supplies the nonce.
- **Unique per chunk.** `index` is in the info, so two slices of one attachment never share an id —
  which matters because `packetId` is the receiver's duplicate-suppression key, and a collision
  would silently drop a slice.
- **Unlinkable.** Indistinguishable from random without the pairwise key, so the header reveals no
  more than a random id did.
- **Key-separated.** A different label from §5.6a's, so the packet-id stream and the nonce stream
  are not the same stream. The vector carries both derived keys side by side, so an implementation
  that reused one label fails there rather than in the field.

#### What this does NOT settle

`sequence` (§5.1) is still the sender's own counter and is sealed inside the payload. A streamed
sender must record the value used for a chunk and reuse it on every retry. Deriving it was
considered and rejected: it is a payload field with ordering meaning of its own, and overloading it
with a second job is the kind of double duty §5.6a's key separation exists to avoid.

### 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,
  "routes": { "b": bool, "l": bool, "i": bool } | absent, ... }
```

Every field after `name` is **optional and appended** (`DECISIONS.md` J72a): a decoder ignores keys
it does not know and treats absent keys as absent rather than as an error, which is what lets a card
cross between builds and between platforms.

`routes` is the sender's **delivery policy** (`DECISIONS.md` J8): which pathways they will accept
being reached on — `b` Bluetooth, `l` the local network, `i` the Internet mailbox. The three are
independent (J11: *"individually chosen preferences as to what pipes are available"*, never a
bundle).

Two rules about it are load-bearing:

- **Absent means undeclared, not denied.** A receiver leaves any policy it already holds untouched
  when a card carries no `routes`. An older build says nothing, and treating silence as a retraction
  would shut every pathway the moment one un-upgraded peer sent a card.
- **No expiry travels.** A declaration is trusted for a bounded window (14 days), and that window is
  stamped by the **receiver** at the moment the card arrives. A sender therefore cannot declare
  itself trusted indefinitely, and the freshness that matters — *how long since we heard this* — is
  the only thing either side can actually measure. Past the window the policy routes as **all
  pathways false**, which is J8's fail-closed rule.

`pkb` (`J1688p`) is the sender's own signed prekey bundle, 129 bytes, base64. A receiver keeps it
only when it verifies and its identity is the sender's -- the same test `J852` applies to a fetched
bundle -- and uses it for one thing: the roster (§5.2.1), so that other members can be introduced to
this contact (§5.2.8). Absent means none offered; a receiver keeps any bundle it already holds.

Distinct from `caps` (`DECISIONS.md` J621), which is on the same card and answers a different
question: a **capability** says what the software can receive and changes with a release; a **route**
says what the person allows and changes with a toggle.

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.8 Device list (`payloadType 8`)

The sender's linked devices, so the receiver can address each one (`DECISIONS.md` J98, J382,
J383, J385). `body` is UTF-8 JSON:

```
{ "v": 1, "devices": [ { "id": "<identity key, 64 lowercase hex>", "prekey": "<PrekeyBundle §6.8, base64>" }, ... ] }
```

At most **ten** devices (`DeviceSession.maxDevices`, both platforms; linking itself stops at six until revocation is built, `J1866`); an empty list is valid and means every device was removed. Each `prekey`
is a signed bundle whose identity **must equal** `id`, or the whole list is refused — a sender may
not hand out a prekey for a device it does not hold.

A receiver opens one session per device (`PrekeyAgreement` as sender, then contact derivation
§6.8), sends the device a hello **through the sender's phone** (§5.9), and from then on seals
**one envelope per device** for everything it sends, each under that device's session and
destination token. A device omitted from a later list is dropped immediately. Sent after a device
is linked or removed and again whenever the list changes; never filed, never notified.

**`again` (`J2197b`).** A phone may send ONE contact its current list with a top-level
`"again": [ "<identity key, 64 lowercase hex>", ... ]` (at most ten), naming devices that list carries
whose session with this contact must be made anew -- because the device says it holds no keys for the
contact (§5.16b). A receiver acts on it only when the list opened under the person's own session (the
rule above) and only for identities the same list carries: it drops that device's session and opens a
fresh one, with a fresh hello (§5.9), exactly as for a newly listed device -- unless the session's hello
was first sent less than an hour ago, which is left to its own retries. A session with no stored hello
(made by a build before `J2100`) is always remade. Malformed or unlisted entries are ignored, never a
reason to refuse the list. A reader that predates the key ignores it, as it ignores any key it does not
know; the phone never stamps this list as the one announced (`deviceListSentHash`).

A policy update (§5.5) may also carry `"linkedBy": true`, sent by a phone to a device it has just
paired in person, meaning *you are now one of my devices*. **A receiver ignores it** (`J1561a`). A
device becomes linked only on its own owner's action: the person holding it chooses Receive a
Profile, and on that screen's pairing completing the device records the other side as its phone
and answers with a one-entry device list of its own bundle. The claim is still sent, for builds
that predate that screen. Honouring it let any recent contact claim the link, and a linked phone
may erase the device (`J925`).

### 5.8a Device list, version 2 -- a signed chain (`J1866` step 3; core `device_list.rs`; READERS FIRST)

Version 1 above is unsigned and only the person's first phone can send it. Version 2 lets every device
of a person keep the list, and lets a contact check it, without the person's identity secret `P` ever
leaving the phone it was made on. **No build writes version 2 yet; this section is the readers' rule.**

**Body** (signed; big-endian; every length checked before allocation; trailing bytes refused):

```
"channel/device-list-v2\0" | P (32) | seq (u64) | prev (32)
| u16 n<=10 | n x ( id (32) | u16 129 | PrekeyBundle (129, §6.8) | u16 a<=64 | hello address (a) )   -- sorted by id
| u16 g<=64 | g x tombstone id (32)                                                                -- sorted
| u16 s 1..10 | s x signer id (32)                                                                 -- sorted
then: u16 s | s x Ed25519 signature (64) over everything above, in signer order
```

**Genesis**: `seq` 1, `prev` all zero, no tombstones, at least one device, signed by `P` alone.

**Every later link**, accepted only against the link the reader holds:

1. the same `P`; `prev` = SHA-256 of the held body; `seq` greater by 1 to 16;
2. each bundle verifies (`verify_strict`) and names its own `id`; no listed id is tombstoned;
3. tombstones only grow; **a device leaves only by being tombstoned in that link** -- never by
   omission;
4. signed by `max(1, min(2, n_previous - 1))` devices **of the previous link**, none of them a device
   this link removes; this holds for an add, a removal and a changed bundle alike;
5. **or** signed by exactly one device that removes only itself and changes nothing else
   (self-removal; the last device may empty the list this way and no other way).

The same `seq` with a different body is a **conflict**: refused, shown to the person, settled only by a
higher `seq` that meets rule 4. A reader that missed links asks any session of the person for them and
applies them in order. **With two devices rule 4 asks one signature, so a stolen unlocked phone can
remove the other before it is removed** -- stated, not protected; the PIN lock and the distress code are
the defence there.

**Readers, before any writer:** a version-2 list never deletes by omission; "my own bundle" (a device to
its phone) and "the person's membership" are different payloads; hellos do not require a session with
`P`'s key; the chain state (`P`, `seq`, the held hash, devices, tombstones) is stored with the sessions;
a list naming an id already on another card, or this device's own, is refused whole; once a person has
sent `dl2`, unsigned version 1 from them is refused. Writers ship only once the readers are installed.

**Superseded by §5.8b**, which applies the 5 October 2026 review (`DECISIONS.md` `J1866`, "step 3
reviewed again"): every membership change co-signed, two devices disputed rather than one-signer,
`seq` by exactly 1, no tie-break, no lone self-removal. This section describes `device_list.rs` and
vector 19, which stay only until both apps call §5.8b's verifier instead.

### 5.8b Device list, version 2 -- the verifier (`J1866b`; core `device_list_v2.rs`; vector 29)

The rule every reader applies to a person's chain of device lists, implemented once in the core
(`device_list_v2::verify_next`, `verify_pair`, `verify_listing`; FFI
`meshcore_device_list_v2_verify`, `_pair`, `_listing`, `_body`, `_encode`). Where `DECISIONS.md`
was open to two readings, the stricter one is taken and named below as **stricter reading**.

**Wire** (big-endian; the whole list at most **5,062** bytes, checked before anything is read; every
count checked before allocation; trailing bytes refused):

```
body = "channel/device-list-v2\0"            (23)
     | seq (u64) | prev (32) | issued_at (u64, seconds since 1970)
     | u16 n<=11 | n x ( key (32) | since (u64) | u16 129 | PrekeyBundle v1 (129, §6.8) )   -- sorted by key, unique
     | u16 g<=64 | g x tombstone key (32)                                                  -- sorted, unique
list = body | u16 s 1..11 | s x ( signer key (32) | Ed25519 signature (64) over body )     -- sorted by signer, unique
```

- `prev` is **SHA-256 of the parent's body** -- signatures excluded, so the same body carrying a
  different set of valid signatures is the same parent and cannot split readers (*chosen over hashing
  the signatures too, which would let one signer make two readers disagree on a parent*).
- `issued_at` is inside the signed bytes because a published listing expires 7 days after it.
- `since` is the `seq` a device was admitted at (0 for genesis devices). It is signed so every reader
  applies "admitted at n cannot co-sign n + 1" from the list alone (**stricter reading**: the
  decisions name the rule but no field; carrying it is the only way two readers agree without a
  second store).
- At most **11** devices (ten linked plus the one they were linked from), **64** tombstones (every
  slot of a full person replaced five times over; a removal past that is refused), **11** signatures.
- Bundles are v1, exactly 129 bytes, each verifying (`verify_strict`) under its own entry's key.

**Genesis** (the reader holds nothing for `P`): `seq` 1, `prev` all zero, no tombstones, at least one
device, every `since` 0, and **exactly one signature, by `P`** (**stricter reading**: no co-signers at
genesis).

**A child** of the held list `H`, checked in this order (the first failure is the refusal code):

| rule | refusal (code) |
|---|---|
| `issued_at` <= now + 600 s | FromTheFuture (7) |
| `seq` <= `H.seq`: same `seq` and `prev`, other body = sibling; otherwise replay | Sibling (4), Replay (3) |
| `seq` = `H.seq` + 1 exactly | SeqSkip (5) |
| `prev` = SHA-256(`H` body) | WrongParent (6) |
| `issued_at` >= `H.issued_at` | ClockBackwards (8) |
| every bundle verifies and names its own key | BadBundle (9) |
| no listed key is tombstoned | Tombstoned (10) |
| every signature verifies over the body | BadSignature (11) |
| every signer is a device of `H` (the newcomer never signs) | SignerNotHeld (12) |
| every device of `H` that is gone is tombstoned here | OmittedWithoutTombstone (13) |
| tombstones = `H`'s plus exactly the removed (**stricter**: none pre-emptive) | TombstoneMismatch (14) |
| a new device's `since` = `seq`; a kept device's `since` unchanged | SinceMismatch (15) |
| a kept device's bundle changes only with that device signing (**stricter**) | BundleSwap (16) |
| no devices left only from a one-device `H` | Empty (18) |
| **membership change** (anyone added or removed): at least `min(2, devices of H)` signers of `H` with `since` < `H.seq` | Quorum (17) |
| **no membership change** (a refresh): one signer of `H`, any `since` | -- |

- **Two devices is a disputed state.** If `H` has two devices and a list only REMOVES, signed by one
  signature, the verdict is **Disputed**: the reader keeps `H`, and the person is told. This holds
  when the signer removes itself too (**stricter reading**: no lone self-removal path survives the
  5 Oct rule that any removal needs two). An add or a swap at two devices with one signature is
  **Quorum**.
- **Admitted at n, signs at n + 2.** A device admitted at `seq` n does not count toward a membership
  change at n + 1. A refresh (signed by any held device) moves the chain on, after which it counts;
  that is also how a person whose only device was admitted in the last list continues.
- **A fork** is two different children of one parent, each of which would be accepted or disputed on
  its own (`verify_pair`); the reader keeps `H` and tells the person. Never tie-broken. A refused
  candidate cannot manufacture a fork.
- **Signers in a refresh need not be eligible** (**chosen**, not stricter): otherwise a person whose
  every device is newly admitted could never move again, and a refresh changes nobody's membership.

**Find me by -- a published listing**: `u16 count<=128 | count x (u16 len | list)`, the chain from
what the fetcher holds (from genesis when it holds nothing). Every link must be **accepted** in order
(a disputed or forked link refuses the listing, LinkNotAccepted 20); the head must have
`now - issued_at <= 7 days` (Stale 19) and no more than 600 s ahead. Nothing held and no links is
NoChain (21). To stay findable, any device signs a refresh within the week.

**Superseded for a person's hosts by §5.8c** (`J1866e`): its co-signing quorum contradicts the owner's
decision that the two devices in a share are the whole approval. The verifier stays until nothing calls it.

**Measured in `judge` (8 Oct 2026), for `J1866d`:** the eligible signers of a membership change are
counted without excluding a device the link removes, so a removal at two devices signed by both (the
leaving device consenting) is accepted, and only a one-signature removal there is Disputed. **Proposed,
not in the verifier** (`designs/J1866-onward-sharing.md` Q4, the owner's call): a one-signature removal at
two devices taking effect after 7 days unless the removed device signs a newer link meanwhile.

**Host status codes (`J2586d`).** A seal refused because the payload does not fit one message (a length bound, the core's `TooLarge`) is `MESHCORE_ERR_PAYLOAD_TOO_LARGE` (-14), distinct from `MESHCORE_ERR_PROTOCOL` (-5), which every other compose failure still answers; it is deterministic, so a host does not retry it unchanged. The codes live in `meshcore.h`.

**Malformed bytes** are not refusals; the FFI reports them as `MESHCORE_ERR_PROTOCOL` with a code from
101 (TooLarge) to 111 (TooManyLinks). Vector `29-device-list-v2.json` covers genesis, a refresh, a
valid add, the refused clone add (alone and co-signed by the clone), the n + 1 exile, wrong `prev`,
`seq` skip, replay, a tombstoned key re-added, an unsigned bundle, Disputed, Fork, and a listing at
exactly seven days and one second past.

### 5.8c The host roll -- a profile's hosts, written by any host alone (`J1866e`; core built, step 1; hosts READ it, step 3; contacts READ it, step 4; the origin WRITES it, step 5, behind a switch that is off)

Design: `designs/J1866-travelling-profiles.md`. **No build sends any of this yet.** The core implements it
(`host_roll.rs`, vector `34-host-roll`; no FFI yet, that is step 2). It replaces §5.8b and
§5.10a (and the origin's sole authority in §5.8, §5.9 and §5.10) once built; until then those sections
describe what ships. Why §5.8b's chain does not carry over: under `J1866e` the giver alone writes an add
and a host alone leaves; §5.8b refuses both at two or more devices (Quorum, Disputed), and without its
quorum its never-tie-broken forks would be routine. The roll is order-free instead.

**Statement** = `body | Ed25519 signature (64) over body by author`. Body, big-endian, each kind exactly its own
size (with the signature: root 164 + 163 per host, at most 1794; add 326, or 462 stamped; leave 162, or 298 stamped;
bundle 293; distrust 199; Master Key set by the only host 362, with its stamp; change 226; evict 198; re-root 325; set
by one of several hosts 362, refusal 330, both with their stamp; this line said *at most 256 bytes* until step 1
measured that no add or root fits in it):

```
"channel/host-roll-v2\0" (21) | P (32) | author (32) | seq (u32) | at (u64) | kind (u8) | payload [| stamp]
kind 0 root:     u16 n 1..10 | n x (key 32 | u16 129 | PrekeyBundle v1 129)   -- author P, seq 0; sorted, unique; P listed
kind 1 add:      key (32) | u16 129 | bundle (129) | flags (u8, bit 0 transfer)
kind 2 leave:    (empty)                                                     -- the author leaves
kind 3 bundle:   u16 129 | bundle (129)                                      -- names the author
kind 4 distrust: subject (32) | cut (u32) | flags (u8, bit 0 erase asked)    -- only if the owner adopts option B
kind 5           (withdrawn: the joint removal; refused as unknown, never reused)
kind 6 Master Key, set by the only host: R (32) | RS (32) | stamp             -- J1866f, stamped since J3280(b)
kind 7           (withdrawn: the set by two hosts side by side; refused, never reused)
kind 8 change:   R' (32) | RS' (32)                                          -- J1866f; author is the old R
kind 9 evict:    subject (32) | cut (u32)                                    -- J1866f; author is R
kind 10 re-root: key (32) | u16 129 | bundle (129)                           -- J1866f; author is R
kind 11 Master Key, set by one of several hosts: R (32) | RS (32) | stamp     -- J1866f; waits 48 h (rule 9)
kind 12 refusal: set (32, SHA-256 of a kind-11 statement's body) | stamp      -- J1866f; any host, within the 48 h
stamp:           proof (64) | relay time (u64) | relay signature (64)        -- kinds 6, 11 and 12 always; 1 and 2 by bit 7
```

**Bit 7 of the kind byte** (`J3280`(b), 31 Oct 2026; 0x81 an add, 0x82 a leave) says the add or leave carries a stamp.
It is in the body, so the proof and the signature cover it; on any other kind it is refused (205). A writer stamps
every add and leave it can. One it could not (an in-person share with no route to the relay) is valid and ranks
last; its giver adds the key again, stamped, when it next can.

**v2** (`J1866f`, 9 Oct 2026): kinds 5 and 7 withdrawn, 11 and 12 added. No v1 roll was ever written outside
tests (the writer has shipped off), so v1 is simply not read.

The **Master Key** (in code the recovery key; `R` its Ed25519 public key and `RS` its X25519 seal key, §5.8f,
§5.8h) is what removes a host: no host removes another, alone or with a second host side by side (the owner, 9
Oct 2026: removal or setting by two devices side by side is a game the real owner could lose; the Master Key is
what makes the owner king). The R seed (`recovery_key::derive`) signs kinds 8-10 here, and a recovery lift in
§5.8d, through the same signers and nothing else. Refused as malformed (code 225, `BadSigners`): a Master Key
equal to the key that signs it; an eviction, change or re-root naming its own author. A re-root's bundle
verifies under its key.

**Stamps** (kinds 6, 11 and 12, and a stamped add or leave). A stamp is `J1817`'s relay time (§5.8e) for the nonce
`SHA-256("channel/host-roll-nonce-v1\0" | proof)[0..16]`, where `proof` is the author's Ed25519 signature over
`"channel/host-roll-stamp-v1\0" | the body before the stamp`; the stamp sits at the end of the body, so the
statement's own signature covers it. The proof makes the nonce one only the holder of the author's key can ask
for, and binds the stamp to this one statement (seq, clock, payload): it cannot be fetched in advance by
anybody who does not yet hold the key, nor moved into another statement. A writer asks the core for the proof
and nonce (`roll_stamp_request`), fetches the relay's `{ t, sig }` for the nonce, then builds the body with the
stamp. A statement whose proof is not its author's is refused (code 226, `BadStamp`) both as it arrives and in
evaluation; one whose RELAY signature does not verify under the relay key the reader trusts is refused as it
arrives (227, `BadRelayTime`) and **ignored** in evaluation, as if absent, so a relay key the reader no longer
trusts cannot stop it reading the roll.

**A stamp is the only time a rule may trust** (`J3280`(b)): it proves a statement was signed at that relay time.
`seq` and `at` are the author's own word and a thief holding the key can write any value; `seq` still orders one
author's statements (rules 1 and 2), `at` orders unstamped adds among themselves, and neither decides who is a host
ahead of a stamp. A stamp proves when a statement was signed, **never when it was shown** (see the limits below).

`seq` counts the author's own statements from 1 and is persisted before signing. Only the root has `seq` 0. Flag bits
not listed are refused, as is a distrust naming its own author. Every bundle verifies (`verify_strict`) under the key it names. A roll is `u16 count <= 512 | count x
(u16 len | statement)`, at most one entry of a root's size; bounds are checked before anything is read. Two
statements with one body are one statement. An author may sign at most 256 statements (the root counts as one of
`P`'s); one that signs more is not refused but removed (rule 0 below).

**Hosts** (one function in the core; the same statements give the same answer in any order):

0. Bounds (`J1866f`, 9 Oct 2026), in this order, and never a refusal:
   a. A statement whose author is not reachable -- listed by the root, or added (by any add, counting or
      not) by a reachable author -- is dropped before anything is counted. It could never count, and
      many keys nobody added must not be able to push the set past its bound.
   b. Over the cap: an author with more than 256 distinct statements (the root counting as one of `P`'s)
      is removed, and none of its statements counts -- not its first 256, since which those are would
      depend on arrival order. Statements count toward an author only if they verify under its key, so
      only the holder of a key can put it over.
   c. Past the bound: while what remains, the root included, is more than 512, the author with the most
      remaining statements (the root counting as one of `P`'s), then the lower key bytes among equals,
      is removed the same way. Several keys one thief holds, each near the cap, go before honest hosts
      with a few statements.
   An author removed by b or c is flagged *over the cap*. The held root always stays and still lists the
   first hosts.
1. Two different statements with one author and `seq`: nothing by that author at or after the lowest such
   `seq` counts, and the author is removed (equivocation). No other ordering among statements is used.
2. Nothing by an author with a `seq` above its own `leave` counts.
3. Ever admitted: the root's keys, then every key added by an ever-admitted author.
4. A distrust counts when its author is ever admitted. A distrusted key's adds and bundles above the lowest
   cut given to it do not count. Distrusts are never cut.
5. Admitted: the root's keys, then every key added, by a statement still counting, by an admitted author.
6. Removed, for ever: every key that left, was distrusted, equivocated, or was removed by rule 0 -- or
   (`J1866f`) by the Master Key (rule 9).
7. A key's admission is the root if it lists the key (after a re-root, the re-root for its key), else the
   counting add of it by an admitted author: a stamped add before an unstamped one, then the earliest stamp (for
   unstamped, the lowest `at`), then the lowest SHA-256 of its body. So a thief's adds, signed after he took a
   host, rank behind every add stamped before him, whatever `at` they claim (`J3280`(b)). `admitted_at` is that time.
8. Hosts: admitted minus removed, ordered by admission (root keys first, then rule 7's order); the first 10
   are hosts, the rest wait. A host's bundle is its counting `bundle` with the highest `seq`, else its
   admission's.

**The Master Key** (`J1866f`, 9 Oct 2026; design "Recovery key", "Ruling: the Master Key, set by one host"). Rules
0a, 1 and 2 apply to every author, a Master Key included. In rule 0a a key is also *named* -- and so reachable --
when a reachable author sets or changes to it as R (kinds 6, 8, 11) or re-roots to it (kind 10). In rules 4 and 9
"its adds and bundles above the cut do not count" is the same rule; only adds and bundles are cut.

9. **The Master Key**, read on the profile as the root began it:
   a. What *sets* it is read from the set as rule 0a leaves it, before rule 0b removes anything, and is never cut
      afterwards: a thief holding a setter cannot take the key away by flooding or equivocating with it. *Host
      keys* are the root's keys and every key any add from them names (uncut). **A leave silences a host key only
      for what came after it** (`J3280`(b)): a set or refusal at or above the leave's `seq`, or stamped at or after
      a stamped leave. What it signed before stands, so a set in effect survives its author's later transfer.
   b. A set by **the only host** (kind 6) counts when its author still speaks and the roll proves it was the only
      host **at its stamp**: following every add from the root keys, except an add stamped at or after that time
      and the author's own unstamped adds at or above the set's `seq`, reaches the author and no key but keys with
      a leave stamped before that time. (So the owner sets it on a new profile, as the author's first statement, and
      shares afterwards; an unstamped leave proves nothing and that host uses (c).)
   c. A set by **one of several hosts** (kind 11) by a host key takes effect once **48 hours** (172,800 s,
      `MASTER_KEY_WAIT_SECONDS`) of relay time have passed since its stamp -- judged against the reader's latest
      accepted relay time `now` (§5.8e) -- **unless a host key signed a refusal** (kind 12) naming it whose stamp
      is earlier than the set's stamp plus 48 hours. A refused set never takes effect, whenever the reader looks;
      a refusal at or after the 48th hour is too late and ignored. Any host may refuse, the set's own author
      included. **Once a key is in effect, only that key changes it** (the owner, 9 Oct 2026): taking the kind-11
      sets in order of stamp, a set whose stamp is at or after the moment an earlier standing set took effect (its
      stamp + 48 h) is void and never in effect, if that earlier set is unrefused, or was refused only by this set's
      own author (`J3280`(b), so a refusal held back cannot bring its signer's own hidden set to life; void for
      ever). A void set voids nothing. Two sets stamped while neither was yet in effect still contest. Sets still
      within their 48 hours and unrefused, a void one included unless void for ever, are listed
      (`Hosts.pending_recovery`: when each takes effect, the hash a refusal names, its author, R and RS), so a host
      can show its person a set they did not make in time to refuse it. A reader with no relay time, or one the relay key does not verify, holds
      every kind-11 set pending. The app requires the profile's PIN before signing a set or a refusal; the core
      does not see it.
   d. If any (b) counts, the (b)s at the earliest stamp are the anchors and every (c) is ignored (none is pending).
      Otherwise every (c) in effect is. Each distinct `(R, RS)` among them starts a chain. **One chain: settled.
      More than one: contested.**
   e. A chain: a key's statements count while below its own equivocation (a leave means nothing to it). Its
      change is its counting kind 8 with the lowest `seq`, unless that names a key already in the chain; then
      nothing the old key signs at or above that `seq` counts, and the chain goes on with the new key. The last
      key is current. `Hosts.recovery` lists each chain's current `(R, RS)`, sorted.
   f. An **eviction** (kind 9) by a counting key of any chain removes its subject (flag *evicted*); the
      subject's adds and bundles above the lowest cut given to it do not count.
   g. A **re-root** (kind 10) counts only when there is one chain: the one with the highest (place in the chain,
      `seq`). Contested keys can remove, never take.
10. **The world** after a re-root to `N`: admission starts from `N` alone (rule 7: its admission is the
    re-root, its bundle the re-root's); an add counts there only when its author is `N` or a key the old roll
    never reached (*old*: the host keys, and every key reachable by any add from an earlier counting re-root's
    key). So `N` and what `N` adds onward are the hosts; a key the old roll held that `N` adds again is a host,
    and what it added before does not come back with it; an old host's add of `N`'s new device changes nothing.
    Every key the roll ever held, or the old roll reached, that is not admitted in the new world is removed
    (*evicted*). Without a re-root the world is the root's.

(Rule 11, the joint removal, and the probation by relay time briefly built for kind 7 are withdrawn with those
kinds.)

**What this gives against a thief holding ONE host.** No statement any host signs, alone or beside another,
removes anybody. The thief can set a Master Key of his own (kind 11): it does nothing for 48 hours, and any of the
owner's hosts refuses it in that time; refused, it never takes effect. He can refuse the owner's kind-11 set the
same way -- a stand-off in which neither key takes effect -- but **a Master Key set while the profile had one host
(kind 6) outranks every kind-11 set, refused or not, so the owner who made the key at creation wins outright.**
If the owner sees nothing for 48 hours, the thief's set takes effect; if the owner's set had already taken effect,
the two contest (each evicts, neither re-roots). A thief holding two hosts is not otherwise covered, as for
freezing (§5.8d).

**Limits of rule 9, stated (`J3280`(b), all on kind 11; none reaches a key set by the only host).** A stamp shows when a
statement was signed and nothing shows when it was published, so three things stand: (1) a refusal stamped in the
window and held back still refuses when it arrives, unseating a key already in effect and undoing its evictions. No
rule on statements can tell it from a refusal sent in time, because the thief's refusal of the owner's set and the
owner's of the thief's are the same bytes. It gives the signer the stand-off an open refusal would have, and not the
key. (2) A set held back past its own 48 hours is in effect the moment it arrives, with no window to refuse it. (3) A
host that refuses a set and later signs its own after that set's 48 hours is void under the rule above: set from
another host, or within the 48 hours. So a profile should get its key by kind 6, at or before its first share; only a
profile shared before the Master Key existed needs kind 11.

**Two hosts.** Neither host can remove the other. The owner who keeps one of two devices uses the Master Key
(evict the other, or re-root), or the other host leaves on its own.

Ties: root keys among themselves by key. The core's `evaluate_roll(root, statements, relay key, now)` reads at
most 1024 statements (twice a roll, so a full roll fits beside one author's statements past its cap), checks every
input first and refuses the whole call on any statement that is malformed, unsigned, with a proof that is not its
author's, for another `P`, or a second root; once those pass it never refuses on counts (rule 0). `now` is the one
input that differs between readers holding the same statements, and only while a kind-11 set is within its 48
hours. A reader filters with `check_roll_statement(bytes, P, relay key)` as statements arrive. Its `removed`
lists only keys the roll ever held (or, after a re-root, that the old roll reached), so a stranger's own `leave`
names nobody. While option B's switch (`DISTRUST_ENABLED`) is off, a distrust parses but has no force and the core
will not sign one.

**Root**: a reader holds one root per `P`, the first it accepts, and refuses any other. The origin (the
host whose key is `P`) writes it, listing itself and its linked devices; a device takes it only from its
linking device (opened under that device's own identity, root `P` equal to it, this device listed with the
bundle it sent); a contact takes it only signed by the card's own `P`.

**Spreading**: a host sends what it writes, and passes on what it learns, to every host and contact it holds
a session with; a duplicate is dropped. Readers summarise what they hold as `[{"a": author hex, "s": seq}]`
(`rollHave`, at most 64) and ask for the rest (`rollFrom`). Between hosts these travel as profile-sync keys
(§5.10) `roll`, `rollHave`, `rollFrom`, `intro`, `caps`; to contacts as payload type 8 `{"v":3,"roll":
base64,"have":[...]}`, taken from any session of the card (older readers refuse `v:3`). A statement's route
is never a reason to trust it. Until a reader has caught up it may still send to a removed host; every host
passes on every statement at once to keep that short.

**Contacts** seal to every host of the roll they hold a session with; make a session with a new host from
its listed bundle and send it a §5.9 hello through any host they hold a session with, which forwards it
only to a host of its own roll; drop a removed host's session at once, `P`'s main session included; and,
once they hold a root for a card, refuse §5.8 v1 for it (before that, v1 only from `P`'s own session, as
§5.10 says). While the `P` host is a host it still sends v1,
projected from the roll, to contacts that do not advertise `roll1`.

**Contacts, as built (step 4; readers only).** A contact keeps one roll per CARD, by the card's `P` (the pairing
key), in a sealed side file `person-rolls.json` (a map from `P` in hex to a roll book: root, verified statements).
It never writes a root, a statement or a summary of its own.

- *Wire.* A device list (payload type 8) with `"v":3` is read by its own function, never by the version-1 reader.
  Keys, all optional but at least one required: `roll` (base64 of a roll encoding, at most 16,384 raw bytes and 40
  statements), `have` and `from` (`[{"a": 64 hex author, "s": seq}]`, at most 64, authors unique, `s` a u32), and
  `again` (`J2197b`, at most ten hex identities). The whole body is at most 32,768 bytes, checked before parsing; one
  value out of bounds refuses the whole message. `{"v":3,"from":[...]}` is the only version-3 body a contact sends:
  it asks a session of the card for what it lacks, when a `have` it just read is ahead of its own, at most once a
  minute per card. Hosts answer such an ask in step 5; a contact answers nobody.
- *Who may deliver.* Any session of the card: the person's own or a device's. With no root held, a root is taken
  from any of them when it is signed by the card's own `P` (it proves itself; the first root taken is the only one
  held, any other is counted and dropped). Once a root is held, only a session whose key the roll lists as a host
  may deliver, and a statement is kept only when the roll already knows its author (the host function's bounds, 512
  statements and 256 per author, apply).
- *Sessions.* A contact holds a session with every host the roll lists (made from the bundle in the roll, hello
  stored with it) and with no other key. A key the roll removes, or lists only as waiting, loses its session at once,
  `P`'s own included (nothing new is sealed to it, whoever asks, and nothing it sends is read); the card stays keyed
  by `P`. A removed key is never given a session again. An identity that is on another card, or is the card's own
  key, never becomes a session (the version-1 rules `J437`, `J2568`).
- *Hellos.* A new host's hello (§5.9) goes first through the session of the statement's author (that of `P` for a
  key the root lists), then the person's own session, then the other hosts, hosts that have answered before those
  that have not. The retry sweep (`J1979`) takes the next of them on each attempt. A host forwards a hello only to a
  host of its own roll (a profile with no roll forwards to any link end, as before), and a device accepts a hello
  from any link end its roll lists, not only the phone that linked it.
- *Downgrade.* Once a root is held for a card, a version-1 list for it is refused, whoever sealed it.
- *`again`* (`J2197b`) is read from any host's session, for a key that is a host; the session is made anew by the
  version-1 rule (a hello first sent under an hour ago is left to its retries).
- *Not a change of the wire for older builds.* A contact on a build without this reads `v:3` as malformed and keeps
  what it holds; the version-1 list is still what it learns from (§11.3, step 5 projects it from the roll).

**The writer, as built (step 5; `HostRollWriter`, `MessageStore+RollWriter.swift` / `MessageStoreRollWriter.kt`).**

- *The switch.* `HostRollWriter.enabled` / `ENABLED` is **false** in every shipped build. The root is written once
  and kept for ever (§4a.3), and from then on a contact on this build reads the roll alone (a version-1 list for
  its card is refused). Under today's flows the origin can drop a linked device from ITS OWN list but cannot sign
  that device's `leave` (the core signs a statement only for the author of the seed) and `distrust` is off, so a
  device the person removes would stay a host for every contact on this build: the lost-device question of the
  design (section 10) decides. Reading a root, answering an ask and the version-1 projection are on regardless;
  they act only once a root is held.
- *When the root is written.* On the origin only (a profile that came from another device never writes one,
  §11.2), with the switch on, when no root is held and at least one linked device has answered with its bundle.
  The first share therefore writes it; an existing shared profile writes it at the next announcement (launch, the
  retry sweep, a device answering). It lists this device with a bundle signed now, then each answered linked
  device with the bundle it sent (at most ten in all; the core sorts them), at the origin's clock in seconds.
- *Adds.* For each answered linked device the roll does not know at all (not a host, not waiting, not removed) the
  origin signs an `add` (transfer flag 0) with its next `seq`. That `seq` is **reserved in the roll book
  (`ownSeq`) and saved before the statement is signed**, so a crash leaves a gap, never two statements at one
  `seq`. A removed key is never added again. Nothing is written for a removal: see the switch.
- *To the profile's devices.* The root, then each statement, as profile sync `roll` with `rollHave` on the last
  message and `caps` `["roll1"]` (§ above); and the whole roll again when a device answers its giver with `caps`
  containing `roll1` and the root lists it (a device the root does not list cannot take a root, §11.1, and is
  sent none by this path; it receives the adds and answers its asks as any host).
- *To contacts.* A card capability tag `roll1` (`CardCapability.hostRoll`, in every card from this build) says a
  contact reads the roll. To such a contact the host sends the whole roll, the root first, as payload type 8
  `{"v":3,"roll":base64,"have":[...]}`, in as many messages as the 40-statement / 16 KiB bounds need, `have` on
  the last only; when the summary (`rollHave`) it last sent that contact is not the current one (the hash is kept
  in `deviceListSentHash`, stamped when the mailbox copy landed). A host sends this for every statement it writes
  or passes on (§7.1). A contact whose card does not say `roll1`, or has not arrived yet, is sent the
  version-1 list, never a version-3 body.
- *The version-1 projection (§11.3).* While a root is held the version-1 list is `{"v":1,"devices":[{"id":hex,
  "prekey":base64}...]}` over the origin's answered linked devices **in link order, kept only if the roll lists
  them as hosts**, with the bundle bytes the device sent; `again` (`J2197b`) after the list. For the same devices
  it is byte for byte what the origin sent before it held a root (tested against an independently written
  builder, with a link order that is not key order). A contact on this build is not sent it.
- *`again` for a contact on this build* is `{"v":3,"again":[hex]}`, for a device the roll lists as a host.
- *A contact's `{"v":3,"from":[...]}` is answered* with `answer(from)` (what the contact lacks, the root only if
  it listed no `P`) as version-3 `roll` messages with no `have`, under the session the ask came over: only for a
  real contact of the profile (not its own record, a link end, a group member, blocked, an AI), only while a root
  is held, only from that contact's own session, at most once in 30 seconds per contact.
- *A device linked after the root* is in no root, so it takes none (§11.1) and holds no roll; it works as before
  until the handover of step 7 gives it the roll, and the contacts and the other hosts see it through its `add`.

**Hosts** open sessions with each other by §6.7.2 v4 introductions to the bundle the roll lists, forwarded by
any host holding both sessions; the target takes one only from a host of its roll, with the listed prekey,
a first message that opens, a new ephemeral, and no live session with that key; a host drops its session
with a removed key at once and forwards nothing to or from it. Profile sync, sent copies,
history and call fan-out run host to host. A host that adopts a roll removing a key tombstones that link
(`J2527`) and drops held renewals (`J2545`).

**Sibling sessions, as built in step 6** (`J1866e`; the wire details the design left open). A host that holds a
root and is a current host sends an **introduction** to every other current host it holds no record of, when its
identity key is the **lower** of the two (unsigned byte order; only the lower introduces, so two hosts introducing
at once can never leave each with a session the other lacks). The introduction is a profile-sync key, bounded before
it is decoded and a bad value refusing the whole message:

| key | value |
|---|---|
| `intro` | `{"to": 64 hex, the target's identity, "code": base64 of one 6.7.2 v4 pairing code, at most 4096 characters}` |

The code is the one `renewThroughOldLink` builds and nothing new: the introducer's own prekey against the bundle the
roll lists for the target (v2 agreement, 6.7.2), name empty, its first message an empty text sealed on a core of its
own so the live core takes no ratchet step before the target agrees. The introducer sends `intro` to every host
session it holds; a **forwarder** (any host) passes it on **unchanged**, and only when the sender is a current host
and the code names the sender, the target is a current host (not the sender, not itself) and it holds a session with
the target. The **target** takes it, quietly and never filed, only when all of these hold, and judges the code, never
the route: it arrived over a session of its own profile with a current host opened under that host's own key; it is
for this device and this device is a current host; the code's identity is a current host of the roll (not removed, not
this device) and the **lower** key; the code's bundle verifies under that identity and its prekey **equals the prekey
the roll lists** for it; the first message opens under the v2 agreement; the ephemeral key has not been taken before
(the last 256 are remembered); and this device holds no record for that key and has not tombstoned it. Having taken
it, the target registers the session (a hidden link end, `isSiblingHost`) and answers with its `rollHave` over it;
anything that opens under the session at the introducer ends the introduction, which until then is sent again every
10 minutes for 7 days and reaches nobody else (an unanswered session is not a host session). A host that learns a key
is removed drops its sibling session and any waiting introduction at once. Over a sibling session a host sends and
reads the roll (`roll`, `rollHave`, `rollFrom`), further introductions and block reports, as it does over any host
session. Sibling sessions form wherever a root is held; nothing here depends on the root writer's switch.

**Sharing** is the giver's `add` of the newcomer's bundle after an in-person pairing on the newcomer's
single-use code (valid while shown, at most 10 minutes, one giver); the giver then hands over the roll, `P`,
profile sync and history. **Transferring** is a share whose giver then signs `leave`. **Taking a profile
off a host** is that host's `leave`, delivered before the host deletes its copy and its identity secret.
No secret key is copied by any of them.

**Sharing, as built in step 7** (`J1866e`; `HostShare`, `MessageStore+HostShare.swift` / `MessageStoreHostShare.kt`).
*Live in every build:* the limits below on today's origin-to-device link, the ten-minute single-giver code, the
newcomer's counts in its answer and the refusal sent to it. *Dormant until a root is written* (the writer is off):
everything that signs an `add`, hands a roll over or lets a host other than the origin give the profile away.

- **Limits** (`scripts/limits.json`): 10 hosts for one profile, 10 profiles on a device, at most **9 of a device's profiles
  shared** (a profile is shared while it has two or more hosts; a transfer from a profile held alone into the tenth slot
  is allowed because it ends with one host, and every other share raises the newcomer's count by one). The giver checks
  before the scan (a profile at 10 hosts, a profile held alone while 9 others are shared, a freeze in force, a PIN
  required and missing) and again on the newcomer's first answer (an older build when a roll is in play, 10 hosts, the
  newcomer at nine); the newcomer checks the roll it is handed (it lists this device with a prekey it sent; it is not
  waiting past the tenth place; it is not the tenth shared profile unless a transfer). A refused share leaves the newcomer
  nothing: no `add`, no roll, the half-made link gone, and both screens say why. The freeze and the PIN are decider
  inputs only: no app holds freeze statements or asks for the PIN first yet, so neither refuses anything in a build.
- **The newcomer's answer** (`deviceList` `{"v":1,"devices":[...],"label":...,"caps":["roll1"],"profiles":n,"shared":m}`):
  `profiles` is how many profiles the newcomer holds, `shared` how many of the others are shared; each a whole number
  0 to 99, anything else reads as not said. Readers that predate them ignore the keys.
- **Profile-sync keys** (each bounded before it is decoded, a bad value refusing the whole message): `refused` (the
  giver to the newcomer: why the share stopped, one of `newer-build`, `hosts-full`, `giver-nine`, `newcomer-nine`,
  `newcomer-full`, `code-expired`, `code-used`, `frozen`, `pin-needed`, `not-listed`, at most 32 characters; taken only
  from the record that is the newcomer's giver, opened under its own key, while no root is held); `donorDone` (a flag,
  from a newcomer to the host that gave it the profile, under its own key, when the donor role is over); `handoverAsk`
  (a flag, from a newcomer to a host that is not its giver, asking for the profile's cards).
- **The add, by any host.** A host that holds a root and is listed in it signs the `add` for a device it linked once that
  device has answered; `P` in the statement is the root's author (the origin's key), the author is the host's own, the
  `seq` is reserved on disk first (§ above), and the transfer flag is set for a target the host is transferring to (step
  8 sets it; nothing in step 7 does). The origin still signs only while its writer switch is on; a host that was given the
  profile has no switch of its own.
- **The handover.** The giver hands the newcomer the whole roll, root first, and the usual first-answer sequence follows
  (profile sync of the cards and own card, the profile's travelling settings and door, group rosters, the history walk).
  The newcomer takes a root as a handover only from the record it was linked by (stamped when the link was made), within a
  week of it, with no root held, opened under that record's own key; `P` is read from the root and the root must verify
  under it; the check that the roll lists this device runs once the statements are in (after every roll message and at
  the retry sweep): it lists it with the prekey this device sent and not past the tenth place, and a share into a device
  that already shares nine is refused unless the add carries the transfer flag and the profile had one host. A roll that
  never lists this device within 10 minutes, lists it with another prekey, or lists it waiting is dropped whole.
  A root from a record without that stamp keeps the strict rule above (root `P` equals the sender's identity, and the
  root lists this device).
- **The donor role** ends when the newcomer's history is complete (the `J1857` G / `J2198` completeness check found
  nothing short) and it holds a session with every other host in the roll; it then sends `donorDone` and the giver stops
  counting itself its donor.
- **Another host carries on.** A newcomer asks its giver first (two minutes after the handover began, and again after a
  short answer); once the giver has been silent for 10 minutes it asks the next host it holds a session with (the others
  in key order, round again). The same question is the history heal (`J2198`), plus `handoverAsk` to a host that is not
  the giver. A host answers only for a host the roll lists, admitted within a week, at most once in 10 minutes per
  newcomer; the newcomer accepts the cards and history only from a sibling the roll lists while its own handover is open.
- **The code** (`Receive a Profile`): valid while its screen is open and at most 10 minutes (a scan completing later is
  refused and leaves no record), and bound to the first giver that pairs on it (a second giver's record is removed). Not
  on the wire.

**Transfer, as built in step 8** (`J1866e`; `HostTransfer`, `MessageStore+HostTransfer.swift` / `MessageStoreHostTransfer.kt`). **No wire change:** a transfer is the step-7 share (an `add` with its transfer flag, kind 1) followed by the giver's own `leave` (kind 2), both existing statements, spread like any other (§7.1). What step 8 fixes:

- **No key is copied.** A profile *travels* when it holds a root or this build writes one (`HostTransfer.travels`). For it, `TransferSnapshot` (identity secret, prekey secret, every session and ratchet book, the seen table) is never built or sent, nothing is frozen, and `TransferWire` parts are never sent. The receiving half of the old Transfer is unchanged. A profile that does not travel (every shipped profile while `HostRollWriter.enabled` is false) keeps today's Transfer.
- **The giver keeps working as a host throughout.** It signs its `leave` when ALL hold: the roll lists the newcomer as a host; the donor role is over (`donorDone` received, §5.4); and, only when the giver is the P host (the root's author), every contact without `roll1` has had the newcomer introduced -- the giver's evidence is that it forwarded that contact's hello (§5.9) to the newcomer -- or 7 days have passed since the transfer began. Time relaxes only the last clause.
- **The `leave` is delivered, then the profile comes off.** It is spread to every host and, as a version-3 list, to every contact on this build; it is also handed to the newcomer directly until a radio or the newcomer's mailbox takes it. The profile is removed when the newcomer has it and every contact on this build has been told the roll as it now stands, or 24 hours after signing.
- **The transfer flag cannot ride in a root.** A root entry has no flag, so a transfer target is not listed in the root: when the only answered devices are transfer targets the root lists the giver alone (a roll of one) and the target is added by an `add` with the flag. That is what lets a profile held alone move into a device's tenth slot.
- **State** (`host-roll.json`, optional keys, an older file reads): `moving` `{target, since, leaveSignedAt, leave, reachedNewcomer, leaveLanded}` and `helloForwarded` (64-byte `device + sender` entries, newest last, at most 1024, written only while a roll is held). A transfer cancelled before the `leave` is signed is a share.

**Taking a profile off a host, as built in step 9** (`J1866e`; `HostTakeOff`, `MessageStore+HostTakeOff.swift` / `MessageStoreHostTakeOff.kt`). **No wire change:** the host's own `leave` (kind 2), signed by `signOwnLeave` and spread like any statement (§7.1). What step 9 fixes:

- **Order.** Sign `leave` (a `seq` reserved on disk first); learn it; pass it to every host session and tell every contact on this build the roll (version-3 list); send it to each other host directly, mailed as well as sent nearby and exempt from the subscription gate (a security action); repeat on every sweep until delivered; then delete the profile.
- **Delivered** is: at least one other host this device holds a session with reported the send as deposited or confirmed (never merely queued); or, when no other host can be reached, every contact on this build has been told the roll as it stands with the leave in it. After 24 hours the profile comes off regardless.
- **Deleted** means `ProfilesStore.delete` (iOS) / `AppContainer.deleteProfile` (Android): the profile's folder, and the identity secret, prekey secret and MLS seed from the Keychain / sealed store, PINs, Secure Mode state and the notification extension's sessions. After it, the key that signed the leave exists nowhere on the device; a statement signed with a copy of it at a higher `seq` does not count (§4a.2 rule 2).
- **Not offered** when the profile is the device's last, is locked, is moving (step 8) or is already leaving. Contacts on older builds (version-1 list) are not waited for: they do not read the roll.
- **State** (`host-roll.json`, optional key, an older file reads): `takingOff` `{since, next, leaveSignedAt, leave, reached, landed, undecided, forced, warned}`.
- **Step 9d: a departed host signs nothing.** `hostDeparted` is true when this device's own key is among the roll's removed keys. It then refuses to sign any roll statement (`signOwnRollStatement`), to run the writer sweep, to announce or summarise the roll, pass statements on, answer an ask or resend the roll (`hostDepartedIdle`: departed with no Take Off or transfer still delivering its leave), to share, transfer or serve a handover (`canShareFromHere`, `canServeHandover`), to renew through the old link, to introduce itself to a sibling, or to sign a bundle for a giver. The last-host decision's Take It Off needs `!heldByLock`. A transfer clears and saves `moving` before the profile is removed.
- **Step 9b.** The 24-hour fallback needs another host in the roll that has not left (`otherHostsRemaining > 0`), or `warned` (this device was the only host when it began, and the person was warned); otherwise `undecided` is set and nothing is removed until the person confirms (`forced`) or cancels. A send's landed report is attributed to the leave it was started for and ignored otherwise. In a transfer (step 8) the giver is removed only when `reachedNewcomer` (the 24 hours relax only the contacts clause); after 24 hours without it `moving.stalled` is set and the person chooses Try Again or Cancel the Move.

**Option B only** (the owner's open question, design §10): a host honours a distrust's *erase asked* only
for itself and only 24 hours after first holding it, and not at all if it signs a distrust of the marker
in that time.

**Between hosts, as built in step 3** (`J1866e`; the wire details the design left open, chosen as the smallest
that fits it). Carried in profile sync (§5.10, payload type 9) by keys older readers ignore, each bounded
before it is decoded and a bad value refusing the whole message:

- `roll`: a roll encoding (above) in base64, at most **40 statements and 16 KiB** raw per message, so a longer
  answer is several messages (a root of ten hosts, 1794 bytes, rides with others; a roll holds at most one
  root-sized entry).
- `rollHave`: `[{"a": 64 hex author, "s": n}]`, at most 64, authors unique. `s` is **how many of that author's
  statements the sender holds without a gap**, counted from the author's first (`seq` 0 for `P`, whose root is
  its first; `seq` 1 for any other host): `s` = the highest `seq` such that every `seq` from the first up to it
  is held, and 0 for an author the sender holds something of but nothing contiguous. A hole therefore shows as
  an `s` below the highest `seq` held. A reader holding no root sends no `rollHave`.
- `rollFrom`: the asker's own `rollHave`. "Send me what is after these": every statement whose author is not
  listed, or whose `seq` is above the listed `s`; the root (author `P`) is included only when `P` is not
  listed. An empty list asks for everything.
- `caps`: `["roll1"]` on every roll message, and at the top level of the answer a device sends its giver
  (`deviceList` `{"v":1,"devices":[...],"label":...,"caps":["roll1"]}`; readers ignore the key).

A reader is **behind** when the sender lists an author it holds nothing of, or an `s` above its own. It then
sends `rollFrom` with its own summary, once per summary received. **An answer to `rollFrom` carries no
`rollHave`** (two readers that cannot satisfy each other would otherwise ask each other for ever). A
statement a reader did not have is passed on, with its summary, to every host session except the one it came
from and except a removed key; a duplicate is dropped quietly.

A host accepts **roll keys only from a link end that has answered** (the phone that linked it, or a device it
linked that sent its bundle), **only when the envelope opened under that record's own identity**, and never
from a key the roll has removed; any other session's roll keys are not read in this step (contacts read a
roll in the next). A **root** is taken only from the record that linked this device (`isLinkedPhone`), opened
under its identity, with root `P` equal to that identity and the root listing this device with **the prekey of
a bundle it sent** (compared by identity and prekey, not by bundle bytes, because a platform may sign a
bundle afresh on every send); the first root taken is the only one held, any other for that `P` is refused and
counted. A **statement** is kept only when the roll already knows its author (a root host, a host, waiting,
or removed), is verified by the core, and fits what a host keeps (below); a chain that arrives
out of order is kept in passes, and what no pass admits is dropped and asked for again. (`J1866f`: a host keeps
up to 257 statements per author -- one past the cap, so every reader sees an author over it whichever arrive
first -- and 1024 in all; the roll's 512 is the core's, by rule 0.) When a key becomes
removed the host tombstones that link (`J2527`) and drops renewals held for it (`J2545`) and stops sending to
or reading from it; the records themselves stay until the steps that retire them. The roll is kept per
profile as a sealed side file (`host-roll.json`); the roster the Shared Profiles screen shows is the core's
hosts once a root is held and the profile has more than one, and the one-time lines (§14 of the design) are
said once, on hosts that were neither the giver nor the newcomer, and never for what a root lists.
### 5.8d Freezing a profile's sharing (`J1866f`; core built, step 1; no app reads or writes it)

Design: `designs/J1866f-lost-device.md` ("Build decision"). **No build sends any of this yet.** The core
implements it (`profile_freeze.rs`, vector `35-profile-freeze`; no FFI yet). Any host may freeze a profile;
while any freeze is in force no host shares or transfers it, and readers refuse newcomers (the club, below).
**Every time is relay time (§5.8e); no rule reads a device clock.**

**Statement** = `body | Ed25519 signature (64) by author`. Body, big-endian, each kind exactly its own size
(signed: freeze 272, extend and lift 271, recovery lift 303):

```
"channel/profile-freeze-v1\0" (26) | P (32) | author (32) | seq (u32) | at (u64) | kind (u8)
  | salt (16) | relay time (u64) | relay signature (64) | payload
kind 1 freeze:     id (16) | days (u8, must be 7)
kind 2 extend:     id (16)                      -- the author's own freeze id
kind 3 lift:       id (16)                      -- the author's own freeze id
kind 4            (withdrawn: the joint lift; refused as unknown, never reused)
kind 5 recovery lift: freeze author (32) | id (16)   -- signed by the profile's Master Key R
```

`seq` counts the author's freeze statements from 1. `at` is the author's clock, for display; no rule reads
it. The relay token is §5.8e's, for the nonce `SHA-256("channel/profile-freeze-nonce-v1\0" | P | author |
salt)[0..16]`; the nonce is not carried, so a token verifies only in a statement by its author for its
profile, and the author's signature covers it. The domain label is distinct from every other message an
identity key signs (checked by a unit test against each). **The freeze PIN is in no statement**: the
freezing host checks it locally before signing an extend or a lift.

A reader takes a statement (`check_freeze_statement`) only if it is well formed, for its `P`, every signature
holds and its relay token verifies under the relay key it trusts. **In force** (`active_freezes(P,
statements, hosts, recovery, relay key, now)`, the same answer in any order; `frozen` is true when that call
refuses):

1. Only current hosts' statements count (the caller passes the hosts from §5.8c), and the recovery lifts of
   `recovery` -- the roll's ONE current Master Key (§5.8c `Hosts.recovery` with exactly one entry; none while it
   is unset or contested); one statement per body.
2. An author with more than 64 statements counts for nothing.
3. An author that signed two bodies at one `seq` counts for nothing from that `seq` on.
4. A statement counts only if its relay token verifies, no other statement of its author carries the same
   salt (one token used twice voids both), and its relay time is no more than 10 minutes older than that
   of its author's statement at `seq` - 1, when held.
5. A freeze is (author, id); two freezes under one (author, id): the lower `seq`.
6. Lifted by its author's lift of the id, or by a recovery lift naming it (counting under 2-4, R being held to
   the same rules as any author). A recovery lift by any other key -- an old R after a change, a host -- counts
   for nothing. Lifted is final. **A freeze ends only so**: by its 7 days, its author's lift, or the Master Key's
   lift. The joint lift by two hosts side by side is withdrawn (the owner, 9 Oct 2026: a thief who shared the
   stolen host to two devices of his own could use it to lift the owner's freeze).
7. Otherwise it runs from its relay time for 7 days (604,800 s); each extension of it by its author, in
   time order, made before the end it then had, moves the end to its own time + 7 days.
8. In force while `now` (a relay time) is before the end. Freezes stack: frozen while any is in force.

The call is refused whole on more than 1024 statements, a `now` that does not verify, or any statement
malformed, for another `P`, or with a bad host signature; a statement whose relay token alone fails is
ignored. **The club** (`club_decision`): while frozen, a reader serves a host key only if it held that key as
a host when the freeze first reached it (its own snapshot; statement dates play no part); a key it refuses
is remembered and stays refused after the freeze ends.

Why a thief holding one host gains nothing: extend and lift reach only their own author's freeze; no number of
hosts lifts another host's freeze; the thief's freeze stacks with the owner's and the owner's Master Key lifts
it; times are the relay's, so nothing is post-dated and every freeze the thief signed before a wipe ends within
7 days of his last relay contact; a flood or an equivocation silences only its signer. **Not covered, by design
or not yet**: an owner with no Master Key waits out each freeze the thief renews while he keeps the stolen device
online and unwiped; a key the thief added offline whose add reaches a reader only after the freeze ended is
never refused.

### 5.8e Relay time (`J1866f`; core built; it is `J1817`'s signed time, which the relay already serves)

A generic signed time, reusable by any feature that must not trust a device clock. **It is the stamp the
relay already serves for erase after inactivity (`J1817`, `server/netlify/functions/time.mts`), one endpoint
and one key.** The client sends `GET /v1/time` with a random 16-byte nonce as 32 **lowercase** hex digits in
the `X-Channel-Time-Nonce` header (never the query string; the relay refuses any other spelling); the relay
answers `{ "t": <Unix seconds>, "sig": <base64> }`, `sig` an Ed25519 signature under `TIME_SIGNING_KEY` over
the UTF-8 string

```
channel-time-v1|<32 lowercase hex of nonce>|<t in decimal, no leading zeros>
```

The app parses that JSON as hostile input and hands the core `t`, the nonce it sent and the 64 signature
bytes; the core's own form of a stamp (inside freeze statements, in vectors) is `time (u64) | nonce (16) |
signature (64)`, and verification rebuilds the string above and checks it strictly. Nothing in the request
or answer names a profile or a freeze. Times are whole seconds, the unit every freeze rule is written in.
The relay's public key is a parameter of every core call; the apps pass the `J1817` key they already pin
(`RELAY_TIME_KEY` in `relay_time.rs`, `RelayTime.swift`, `RelayTime.kt`; preflight holds the three
equal). `accept_time(relay key, last seen, new)` refuses a time that does not verify or is older than the
last one this reader accepted (persisted in protected storage), so replaying an old token cannot wind time
back. At share time an app collects its mail, fetches a fresh relay time, passes it through `accept_time`,
and judges the freeze statements it holds against it (§5.8d). For a freeze statement's token the nonce is
§5.8d's derivation, so its hex is what the app sends; for a host-roll stamp it is §5.8c's (from the proof).

**The latest relay time** (`J1866f`). Every relay time an app verifies (today: the `J1817` fetch of erase after
inactivity) is also kept, through `accept_time`, as the device's latest relay time (`RelayTime.latestToken` on
both platforms; a public relay-signed value, so ordinary preferences). The host roll reads it as `now` to decide
whether a Master Key set by one of several hosts has waited out its 48 hours (§5.8c rule 9c). A device that has
fetched none holds every such set pending.

### 5.8f The recovery key -- parts and derivations (`J1866f`; core `recovery_key.rs`; vector 36; no app reads or writes it)

Optional, one per profile, held off every device (`designs/J1866f-lost-device.md`, "Recovery key"). This
section fixes only the secret, its parts and what it derives; the bundle is §5.8h; the `recovery` roll
statement is §5.8c's kinds 6 and 11; what is posted to the recovery mailbox is §5.8l.

**The secret** is 16 bytes from the device's CSPRNG, supplied to the core (which reads no random source
for it).

**The parts** are SLIP-39 exactly as published (satoshilabs `slip-0039.md`): one group (group threshold
1, group count 1) of `n` member shares with member threshold `k`, `1 < k <= n <= 16`, default **3 of 5**;
extendable flag 1 (so RS1024 uses `"shamir_extendable"` and the Feistel salt prefix is empty); iteration
exponent 1; empty passphrase. Each part is 20 words. Fixed random bytes give fixed words: the identifier
(2 bytes, masked to 15 bits), then `k - 2` free shares of 16 bytes, then the digest share's 12 random
bytes, the reference implementation's draw order. A reader accepts any SLIP-39 set (two-level groups,
256-bit secrets) but refuses an iteration exponent above 5, more than 16 parts, a part of more than
1024 bytes of text or more than 33 words, all before allocating; a rebuilt secret that is not 16 bytes
is not a recovery key. SLIP-39 requires exactly the threshold count of parts per group, and the same
part entered twice is a duplicate member index: both are refused. The official SLIP-39 vectors
(`vectors/slip39-official.json`) all hold.

**Placement** (`plan_ok`): paper parts >= `k`, device parts < `k`, paper + device = `n`.

**Derivations**: HKDF-SHA256 (RFC 5869) with an empty salt and 32-byte output unless stated.

| output | IKM | `info` |
|---|---|---|
| Ed25519 seed of R | the 16-byte secret | `channel/recovery-v1/signing` |
| R | -- | the Ed25519 public key of that seed (RFC 8032) |
| bundle sealing key | the 16-byte secret | `channel/recovery-v1/bundle` |
| recovery mailbox tag, 16 bytes | **R** (32 bytes) | `channel/recovery-v1/mailbox` |

The mailbox tag is derived from R rather than from the secret because every host and every contact must
collect from that mailbox while holding only R. Anyone who learns R can therefore find the mailbox; it
learns nothing it can open there.

### 5.8g Sealed order carriage (`J1866f`; core `order_carriage.rs`; vector 37; no app reads or writes it)

How a profile's orders (§5.8c roll statements, §5.8d freeze statements, and the recovery key's orders)
travel through any CHANNEL device on every route without naming the profile (`designs/J1866f-lost-device.md`,
"Sealed order carriage"). The seal adds privacy, not authority: every statement inside is verified by
its own section, exactly as if it had arrived any other way.

**The carriage secret CS** is 32 bytes from the root host's CSPRNG, given to each new host inside the
sealed share and never to a contact.

**Keys**: `PRK = HKDF-Extract(salt = "channel/carriage-v1", IKM = CS)`; seal key = `HKDF-Expand(PRK,
"order-seal", 32)`; tag key = `HKDF-Expand(PRK, "order-tag", 32)` (HKDF-SHA256, RFC 5869).

**Tag**: `tag(e) = HMAC-SHA256(tag key, u32 e big-endian)[0..16]`, where `e` is the relay day
(§5.8e time / 86,400, as a u32). A host computes it for `e` and `e - 1` (there is no `e - 1` at 0).

**Blob** (exactly 1,024 bytes):

```
tag(e) 16 | nonce 24 | XChaCha20-Poly1305(seal key, nonce, aad = tag(e), plaintext 968)   -- 984 bytes
plaintext = u16 L | list (L bytes, L <= 966) | zero bytes to 968
list      = one or more ( u16 len (>= 1) | statement )
```

The nonce is 24 fresh random bytes for every seal, supplied by the caller (the core reads no random
source); hosts re-seal at every epoch change and on every new order, so one order set never repeats a
ciphertext. A reader refuses a blob that is not 1,024 bytes, whose tag is neither `tag(e)` nor
`tag(e - 1)`, that the AEAD refuses, whose `L` exceeds 966, whose padding holds a non-zero byte, whose
list is empty, or whose statement lengths are zero or run past the list.

**Carrying**: every device keeps at most **256** blobs, none older than **30 days** of relay time since
it first held it, deduplicated by SHA-256 of the whole blob. One connection may add at most **8** new
blobs (a duplicate costs nothing). When full, blobs past 30 days go first, then one chosen by the
caller's random draw. A blob that opened for a profile this device hosts (or one of its own sealed
orders) is kept in a separate protected set of at most **32**, oldest out first, which a flood cannot
reach. On every connection, on every route, a device pushes its blobs -- both sets together, ordered
by hash and rotated by a random offset, so the order reveals nothing about which matched -- up to
**16** on Bluetooth, **64** on Wi-Fi Direct and the local network, **256** on the Internet, whatever
profile the connection is under, and never says which blobs matched. A device opens at most **64**
matching blobs per connection.

### 5.8h The recovery bundle (`J1866f`; core `recovery_bundle.rs`; vector 38 and RFC 9180's own vectors; no app reads or writes it)

What a device given only the recovery key learns about its profile. Each host writes its own bundle,
signed by its own identity key and sealed to a public key derived from the recovery secret, so hosts
can write and only the key's holder can read; a hostile host can withhold, never rewrite. The symmetric
"bundle sealing key" of §5.8f is not used for it.

**The seal key pair**: `rs = clamp(HKDF-Expand(PRK_rec, "recovery-seal", 32))`, `PRK_rec =
HKDF-Extract(salt = empty, IKM = the 16-byte recovery secret)` (the PRK of every §5.8f derivation),
where `clamp` is RFC 7748 §5 applied explicitly: clear bits 0-2 of byte 0, clear bit 7 of byte 31, set
bit 6 of byte 31. `RS = X25519(rs, 9)`. RS travels beside R in the roll's `recovery` statement (not
specified yet).

**Body** (big-endian), signed by the host's Ed25519 identity key (strict verification):

```
"channel/recovery-bundle-sig-v1\0" (31) | P 32 | host key 32 | counter u64 | relay time 88 (§5.8e)
  | u32 n | roll (n bytes, §5.8c's roll encoding, n <= its maximum) -- the statements verbatim
  | u16 c (<= 4096) | c x ( contact key 32 | u16 len (1..4096) | card ) -- sorted by key, unique
  | CS 32
signed = body | signature 64
```

The signer signs nothing but a well-formed body whose host is its own key. A reader bounds every length
before reading it, and refuses trailing bytes.

**Seal**: HPKE base mode, RFC 9180: DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20-Poly1305, to RS,
`info = "channel/recovery-bundle-v1" || R`, empty AAD, sequence 0; the ephemeral key is
`DeriveKeyPair(ikmE)` from 32 random bytes the caller supplies. Sealed form: `enc 32 | ciphertext`.
*The design text binds P in `info` as well. A device recovering from the words alone does not know P
(learning it is what the bundle is for), and P in the clear would link the recovery mailbox to the
profile at the relay, so P is bound by the host's signature instead and checked against the root.*
The implementation must pass the official RFC 9180 vectors for this suite
(`vectors/hpke-rfc9180-x25519-chacha.json`, Appendix A.2.1, all 257 encryptions and 3 exports).

**Opening** checks, in order: the sealed length bound, the AEAD, the body's structure, the host's
signature over it, and the relay time under the relay key the caller trusts.

**Slot**: a host posts its bundle to the recovery mailbox (§5.8f) under `HMAC-SHA256(key = mailbox tag,
host key)[0..16]`, replacing only its own, with a higher counter.

**Union** (on the recovering device, against the profile's root): bundles naming another P are dropped;
every roll statement any remaining bundle carries that §5.8c accepts for P is taken once by body hash,
keeping those whose author is *named* (`J3747`), exactly as §5.8c rule 0a names it: listed by the root, or added, set or
changed to as a recovery key (kinds 6, 8, 11), or re-rooted to (kind 10), by a named author (repeatedly), so the Master
Key's own evictions and re-roots are kept; the roll is
evaluated; a bundle whose signer is not one of its hosts is dropped, and of one host's bundles only
the highest counter (then the newest relay time) counts. The roll's evaluation is §5.8c's, so a host
whose bundle carries more than 256 statements of its own is removed (rule 0) and its bundle is not kept,
rather than the union failing. **Contact cards, by majority** (`J1866f`, 9 Oct 2026): each kept host's
bundle is one vote for the version it carries of each contact key (a host that carries no card for a key
does not vote). The version carried by strictly more kept hosts than any other is the card, and a key on
which some host was outvoted is reported. On a tie at the top no version is chosen: the key is reported
with every version and the hosts carrying each (most hosts first, then by bytes), the app marks the
contact "check before messaging", and the recovered device sends it nothing until the person confirms
one. When more than one host's bundle is kept, a card only one host carries is reported the same way
(single-source: a thief could plant a contact no one else has). When exactly one host's bundle is kept,
its cards are returned as they are and the whole recovery is marked single-source, unconfirmed. Relay
time and counters never decide between different contents -- a thief's host signs a newer relay time as
easily as any other. **CS** by the same rule: the value carried by strictly the most kept
bundles; on a tie none is chosen, the conflict is reported with the tied values, and an outvoted value is
reported.

### 5.8i Attested photo, iPhone (`J1866f` step 5a; core verifier built 11 Oct 2026, no FFI or app yet)

A fresh camera photo may carry proof that it was taken just now, by this phone, inside the genuine
app (`designs/J1866f-lost-device.md` section 4). iOS half: Apple App Attest. Android Key Attestation is
step 5b. Core: `mesh-core-rs/src/attested_photo.rs`; vector `49-attested-photo-statement`.

- **Statement.** The camera key signs, through an App Attest assertion,
  `"channel/attested-photo-v1" || SHA-256(image) || u16be len || relay time token (5.8e) ||
  new profile identity (32) || recipient key (32) || u32be capture_ms_after_token`.
  `clientDataHash` = SHA-256(statement).
- **Attestation, once per key.** Its challenge is the `clientDataHash` of the first statement the key
  signs, so no server is involved. The receiver checks it as Apple's validation guide says:
  - the x5c leaf and intermediate chain to the pinned Apple App Attestation Root CA
    (SHA-256 `1cb9823b...c932`), at the relay-token time, never the receiver's clock (a leaf lives
    about three days);
  - nonce, key id, rpId = SHA-256(TEAMID.bundle), counter 0, aaguid `appattest`
    (`appattestdevelop` only when development builds are accepted), credentialId.

  Beyond Apple's list: the COSE key equals the leaf key, and the CA flags are checked.
- **Assertion, per photo.** ECDSA P-256 with SHA-256 over nonce = SHA-256(authenticatorData ||
  clientDataHash). The rpId is checked, and the counter must be greater than the last one seen for
  that key (the caller keeps it). **Believed, not measured:** Apple publishes no real assertion, so
  "SHA-256 over the nonce" (as other verifiers do it) must be confirmed on a device before an app
  relies on it.
- **Receiver, otherwise.** It also checks the relay token (5.8e) and its age, and that the recipient
  is itself. Any failure shows no badge and never a warning. It proves when, and with what, the
  picture was taken, never who is in it.


### 5.8j Attested photo, Android (`J1866f` step 5b; core verifier `attested_photo_android.rs` built 11 Oct 2026, no FFI or app yet)

The statement and its hash are 5.8i's (`photo_statement`, `statement_hash`, re-exported), so both platforms sign the same bytes.

**Attestation, once per key.** The key is generated in Android Keystore with challenge = SHA-256(first statement), after that statement is known, so no attestation predates its relay token. The receiver does not choose the challenge: the attestation proves "generated after this token, for this first statement". Later photos reuse it, each with its own token and recipient binding (Android cannot re-attest an existing key).

**The receiver verifies** (Google, source.android.com/docs/security/features/keystore/attestation):

- 2 to 6 certificates, the last carrying a pinned key. Roots by SHA-256 of DER, fetched 11 Oct 2026 from android.googleapis.com/attestation/root: RSA-4096 `cedb1cb6dc896ae5ec797348bce9286753c2b38ee71ce0fbe34a9a1248800dfc`, ECDSA P-384 `6d9db4ce6c5c0b293166d08986e05774a8776ceb525d9e4329520de12ba4bcc0`. Anchored by key, not by exact bytes, because Google's older chains end in certificates that carry the same key.
- Each certificate is signed by the next, names match, top down. Signatures are ECDSA P-256 or P-384 with SHA-256 or SHA-384, and RSASSA-PKCS1-v1_5 with SHA-256 only under the pinned root.
- CA, pathLen and keyCertSign are enforced, and a non-CA batch certificate is refused (strict RFC 5280; the Keymaster 3 era is excluded).
- Validity is judged at the relay-token time, never a device clock.
- No serial is in Google's revocation list (`attestation/status`, hex serials, fetched by the app at most daily, directly from Google rather than through the relay).
- Every certificate is framing-checked (each length fits its parent) before `x509-cert` parses it. The same check runs on 5.8i's chain.

**KeyDescription** (OID 1.3.6.1.4.1.11129.2.1.17), in the leaf only:

- both security levels TEE or StrongBox, never Software;
- the challenge as above;
- hardwareEnforced: origin Generated, purpose includes Sign, EC on P-256;
- RootOfTrust: deviceLocked, boot state Verified;
- attestationApplicationId names our package, and at least one listed signing digest is ours;
- the key's active and expire window holds at the token time.

**Photo signature:** ECDSA P-256 over SHA-256(statement), DER (`SHA256withECDSA`). **Believed, not measured:** the digest and DER encoding, until confirmed on a handset.
### 5.8k The Master Key's per-profile keys -- slots and the public-slot cache (`J3544e` stage 1; core `master_key.rs`; vector 64; no app reads or writes it)

One Master Key per person (`designs/J3544e-master-key.md` R4.2): the §5.8f secret, generated, shown once as
SLIP-39 parts. Each profile that gets it uses its own **slot**, so two profiles of one person show contacts
two unrelated `R`s. Nothing here changes §5.8f or §5.8h; they run unchanged on the profile's secret `m_P`.

```
m_P = HKDF-SHA256(salt = empty, IKM = the 16-byte Master Key,
                  info = "channel/master-key-v1/profile" || u16 BE slot, L = 16),   0 <= slot < 1024
R_P, its seed, the bundle key, the mailbox tag = §5.8f on m_P
rs_P, RS_P                                    = §5.8h's seal pair on m_P
```

A slot at or above 1,024 is refused (701). **Drawing a slot** (the core reads no random source): the caller's
random bytes, two at a time, big-endian, masked to ten bits; a slot the caller lists as used, or already
drawn in this call, is skipped; at most 4,096 random bytes and 1,024 used slots, both checked first; refused
702 if the bytes run out. **Finding a slot**: a device holding the parts and a profile's `R` derives slots
0-1023 until one matches (711 if none); a device without the profile computes all 1,024 mailbox tags.

**The public-slot cache** (kept on a device, never sent): `version (1) = 1 | count u16 BE <= 64 | count x
(slot u16 BE | R 32 | RS 32)`, entries in draw order, exact length, no slot twice, every `R` a valid Ed25519
point; anything else is refused (705-709) before anything is allocated beyond the bound. Taking an entry
removes the FIRST; the caller stores the remainder before using it. It holds no secret, so its integrity is
the device store's; with the parts in hand a device re-derives every entry (712 on a mismatch).

### 5.8l What is posted to the recovery mailbox (`J3544e` stage 2; core `recovery_mailbox.rs`; vector 65; no app reads or writes it)

The Master Key's orders, for anyone who holds `R`, and how a device with only the parts finds the bundles.

```
CS_R    = HKDF-SHA256(salt = empty, IKM = R, info = "channel/recovery-v1/orders", 32)
posting = §5.8g's blob, exactly 1,024 bytes, under CS_R at epoch 0, with a fresh random nonce
address = HMAC-SHA256(key = mailbox tag, "channel/recovery-orders-v1")[0..16]
item    = a roll statement signed by a Master Key (kind 8, 9 or 10)
        | a recovery lift (§5.8d kind 5)
        | "channel/recovery-locator-v1\0" (28) | host key (32)         -- a locator
```

**The seal hides the profile from the relay, which sees a mailbox address and a fixed-size blob; it hides nothing
from a holder of `R`, and it grants nothing.** Every host and contact holds `R` and must read these, the evicted
device above all. Each statement is checked by its own section as if it had come any other way. Epoch 0 is fixed
because the address does not rotate: a device away for months must still find the posting, and rotating a tag
would hide nothing from a relay that already sees the address.

**A writer** refuses any other item (802), so nothing else is posted here by mistake. **`pack`** splits at most 64
items into postings: a re-root first, then evictions, changes, lifts, locators, each group by SHA-256, each posting
filled in turn; duplicates are one. So every holder packs one set alike and what must arrive soonest comes first.
**A reader** refuses what §5.8g refuses, skips and counts an item it cannot read so one bad item cannot hide the
others, and opens at most 64 postings per collection.

**Who posts, and for how long.** The device that signs an order posts it. Every host that holds an order posts it again
every 3 days with a fresh nonce (the relay keeps a deposit 7), for as long as the roll holds it, so an evicted device
that returns after months still finds it. A host also posts a locator for itself. The mailbox is an ordinary
append-only one: a holder of `R` can add junk and cannot remove anything, within the relay's mailbox cap.

**Locators fill a gap in §5.8h,** whose slot is `HMAC(mailbox tag, host key)`: a new phone rebuilt from the parts knows
no host key, so without a locator it could not fetch a bundle. A locator is unsigned; a false one costs one fruitless
fetch, because a bundle verifies under its own host's key and §5.8h's union decides among hosts. A new phone reads
each of the 1,024 candidate addresses of §5.8k once.

### 5.9 Device hello (`payloadType 10`)

How a contact's phone and a linked device come to share a session without either ever meeting.
The contact has the device's prekey (§5.8) but no session; the device has its prekey secret but
has never heard of the contact. The phone that linked the device holds sessions with both, so the
hello travels through it. `body` is UTF-8 JSON:

```
{ "v": 1, "device": "<identity key, hex>", "sender": "<identity key, hex>", "ephemeral": "<32 bytes, base64>", "sealed": "<contact card §5.7 sealed under the device session, base64>" }
```

The **contact** sends it to the phone under the contact session. The **phone** checks that
`sender` is the contact it came from and that `device` names one of its linked devices, and forwards
the body byte for byte to that device under the device session; it cannot open `sealed` and does not
need to. The **device** accepts only from the phone that linked it and only when `device` is itself:
it runs `PrekeyAgreement` as recipient with `ephemeral` and its prekey secret, derives the contact
session (§6.8), opens `sealed` — which both proves the derivation and carries the contact's name and
picture — and records the contact at the Linked tier. Never filed, never notified.

### 5.10 Profile sync (`payloadType 9`)

A phone's contacts, for one of its own linked devices (`DECISIONS.md` J320, J387). One direction
only, phone to device, under the session the link ceremony made. `body` is UTF-8 JSON:

```
{ "v": 1,
  "contacts": [ { "id": "<identity key, hex, or empty if never paired>", "card": "<the phone's handle for this card>",
                  "name": "…", "given": "…", "middle": "…", "family": "…", "second": "…", "company": "…", "cover": "…",
                  "phones": [ "…" ], "emails": [ "…" ], "blocked": true }, … ],
  "removed": [ "<card handle>", … ],
  "me": "<the sending profile's name, at most 100 characters>",
  "meCard": { "id": "", "card": "self", "name": "…", "given": "…", … , "phones": [ "…" ], "emails": [ "…" ] } }
```

Three optional keys, each ignored by a reader that predates it:

- **`blocked`** (`J1863`) is present only when true: the card is blocked on this profile, on every device of it.
- **`me`** (`J1857` stage F): the sending profile's display name. A profile made to receive this one
  takes it while it still carries the placeholder name.
- **`meCard`** (`J1903`): the sending profile's **own card**, in the entry shape. It has at most
  twenty phones and twenty emails, each value at most 254 characters, each name part at most 100,
  and a payload breaking any of these bounds is refused whole. The receiver takes each name part the
  sender has filled and adds each value it does not already hold, **with discovery off**. Whether a
  value is published belongs to one identity per slot (`J1036`), and handles never travel (`J520`).
  It is sent with the whole list and whenever the own card's entry changes, **including when no
  other card has changed**, so a profile with no contacts still names its linked devices.

At most **thirty** entries in either list per payload; a longer list is sent as several payloads. A
sender sends the whole list once, when a device first answers with its bundle, and afterwards only
the cards whose entry has changed, or a removal. **Notes never travel** (`J324`). A receiver accepts
only from the phone that linked it: a card it already holds — matched by identity, or by `card` for
one never paired — takes the phone's fields; one it does not hold is created unpaired, carrying the
identity that a later hello (§5.9) attaches keys to; a removal deletes the card and its thread.
Never filed, never notified.

**Asking for the device list** (`J2577`, device to origin): a profile sync from a linked device may carry
`"rosterAsk": true` and nothing else, from a device that holds no list of the profile's devices. The origin answers
only a linked device that has answered with its bundle, no more than once a minute per device, with the usual
`devices` roster to that device alone; a device asks no more than once in ten minutes. Earlier readers ignore the key.

**First-contact requests reach every device of the profile** (`J2581`, both directions; origin to the devices it
linked, a device to the origin, which sends the union on). A request is left at a published value's slot and read
with that value's collection capability; the relay deletes nothing on collection (`J102` s4(b)), so any holder of the
capability reads every invitation for its seven days. Two optional keys on a profile sync carry the rest, **never to a
contact** (`LEGAL-RISK.md` section 9, `J105`; a capability is an HMAC credential and travels only inside this sealed
channel):

- `"slots": [{"v": "<normalised value>", "c": "<expiry ms>.<64 hex>"}, ...]` -- a whole snapshot of what the sender
  collects at: at most 20, values unique and at most 254 characters, each capability at most 128 bytes and of the form
  `<digits>.<hex>`. A receiver replaces what it held FROM THAT SENDER, drops a lapsed capability, keeps the later
  expiry where two devices offer one value, and holds at most 40. It **polls** those slots and never publishes a
  prekey there. The origin sends the union (its own values, then those its devices told it); a device sends its own.
- `"settled": [{"i": "<32 lowercase hex>", "k": "<64 lowercase hex or empty>", "ask": true?}, ...]` -- at most 30
  requests answered on the sender. `i` is the relay's id for the invitation (the first 16 bytes of the SHA-256 of the
  bytes deposited), `k` the sender's identity key. A receiver drops every request with that id **or** that sender
  (one person is one request) and remembers `i`, so an invitation it collects later is not filed. With `"ask": true`
  nothing was answered: the sender holds a request it cannot open (its first message is sealed to the prekey another
  device published), and a device that holds the request and could open it accepts it; every other copy is dropped.
  The origin passes new answers on to its other devices, except the sender. Only the device the profile came from
  accepts every request on its own (`J1972`).

Both keys are ignored by earlier readers, which therefore go on collecting only where they hold the capability.

**Between a person's own devices only, device to origin** (`J1866c`, `designs/J1866-step3-coequal-removal.md`):
a profile sync from a linked device may carry `"revoke": {"id": "<8 lowercase hex: the first four bytes of a
device's identity key>", "at": <seconds since 1970>}`, asking the origin to revoke another device of the
profile. A request outside those bounds refuses the whole payload. The origin applies it only when the sender
is a device it linked that has answered with its bundle, the core opened the envelope under that device's own
identity, exactly one other linked device carries the id and it is not the origin, and `at` is no more than
600 s ahead, no more than 7 days old, and no more than 600 s before that device's link was made in person;
it then revokes that device exactly as its own Revoke does (unlink notice, tombstone, §5.8 list to every
contact, roster to every device left). Earlier readers ignore the key. **The unlink notice** (a policy update,
§5.5, `{"v":1,"unlinked":true}`) gains `"at"`, seconds; a receiver refuses one written more than 600 s before
the link it would end, and takes one without `at` as before. **A §5.8 list is taken only when the envelope
opened under the person's own session**, never one of their device sessions.

**A view-once item spent on one device** (`J2866`, between a person's own devices, either direction): a profile
sync may carry `"spent": [{"c": "<peer>", "m": "<message>"}, ...]`, telling the profile's other
devices that each named view-once item (§5.27, and a held view-once file) was revealed, and so spent, on the
sender. **What `c` and `m` name** (device-local conversation and message UUIDs differ on every device, so they cannot):
`c` is the lowercase hex of the 32-byte identity key of the WRITER of the item (a one-to-one thread's contact, or the
authenticated member of a group), and `m` is the lowercase hex of the writer's 16-byte message id; only inbound view-once
items are named. A receiver marks each item spent exactly as if it had spent it itself and deletes its copy and key at
once; an item it does not hold is remembered as spent, so a copy that arrives later is never shown. Bounds, all
checked on the array before any entry is read, any breach **refusing the whole payload**: at most **30** entries
(the same cap as `contacts` and `removed`; a longer list is sent as several payloads); each of `c` and `m` a JSON
string of 1 to **64** bytes, every byte printable ASCII (0x21 to 0x7E); each entry exactly the two keys `c` and
`m`; no `(c, m)` pair twice. An empty list is the same as no key, and any other value of `spent` (null, an object,
a string) is refused. Key order inside the JSON is not significant (the body is JSON, not canonical CBOR). The
note carries only ids and travels only inside the sealed profile-sync channel to the person's own devices, never
to a contact. Earlier readers ignore the key and keep spending per device. The profile-sync body is read by the
apps alone (the core never parses it), so there is no core codec: `vectors/52-profile-sync-spent.json` is the
conformance file, `mesh-core-rs/tests/conformance.rs` holds the rule to it with a reference reader, and each app's
reader is held to the same file.

### 5.10a A person's own devices keep the §5.8b chain; onward sharing (`J1866d`; PROPOSED, NOT BUILT)

**SUPERSEDED by §5.8c** (`J1866e`, the owner, 8 Oct 2026): no second device approves a share, there is no
removal authority and no permanent origin. Kept below only as the record of the proposal; build nothing from it.

Design: `designs/J1866-onward-sharing.md`. **No build sends any of this yet; it is written before code,
for review, and changes nothing a contact reads.**

**The chain among one person's devices.** Every device of a profile stores the §5.8b chain, every link
from genesis. Genesis is written by the origin (the device whose key is `P`) once every device of the
profile advertises capability `chain1`, listing itself and every linked device that has answered, signed
by `P` alone, and sent to each linked device; a device takes genesis only from its linking device, only
when the envelope opened under that device's own identity, only when the root it is told is that identity,
and only when the list carries this device's own key with the bundle it last sent. Contacts are not sent
the chain: they keep reading §5.8 v1 from the origin's own session, and the origin builds that v1 list
from its head (every device of the head with which it holds a session, less any device whose removal it
has started and that still lacks its second signature).

**Profile-sync keys between one person's devices** (§5.10's body; each optional, ignored by an earlier
reader, every value bounded before decoding, a value out of bounds refusing the whole payload). Accepted
only from a session whose envelope opened under a key of the receiver's head, except genesis and a
newcomer's first `chainLinks` and `root`, which come from its linking device:

| key | value |
|---|---|
| `chain` | base64 of one §5.8b list (a commit or genesis), at most 5,062 bytes decoded |
| `chainLinks` | base64 of a §5.8b listing encoding (at most 128 links) |
| `chainFrom` | 64 hex: SHA-256 of the asker's head body; the answer is `chainLinks` after it |
| `root` | 64 hex: `P`, from the linking device to a newcomer only |
| `propose` | `{"list": base64 candidate signed by its proposer only, "label": "<newcomer's own label, at most 100 characters>", "at": seconds}` |
| `decline` | `{"prev": 64 hex, "seq": n}` |
| `intro` | `{"to": 64 hex key, "code": base64 §6.7.2 v4 pairing code}`, forwarded unchanged by a device holding sessions with both |
| `resolve` | base64 listing of the chosen branch of a fork, from its fork point |

**Adopting a link**: `meshcore_device_list_v2_verify(head, link) == accept`. An add is additionally
refused unless one of its signers is not its proposer.

**A proposal is shown to the person only when** it opened under a device of the head; the core answers
`Quorum` (17) against this device's head (or, for a one-device head, `Accept`); it carries exactly one
signature, by that device; the core's change report is exactly one added key, nothing removed, no bundle
changed; the proposer is not being removed; the new key is not this device's, not on another card and not
tombstoned; the result has at most 10 devices; `at` is at most 600 s ahead and 24 h old; this device has
not signed another child of the same parent; it is the proposer's only open proposal and at most its
third in 24 h. Allow re-runs every check, records `(prev, body hash)` before signing, signs the body with
this device's key, and sends the commit to every device of the new head it reaches. **A device never signs
two different children of one parent.** Only after adopting the commit does the linking device send the
newcomer `chainLinks` from genesis with `root`, and only then does it share the profile's contents.

**Automatic co-signatures** (no person asked), never of an add or of another device's bundle: a link whose
only change is removing its proposer; the origin co-signing a device's §5.10 `revoke` request (never one
removing the origin); any device co-signing the origin's own revoke of another device. **None of these
takes a head from two devices to one** except the first.

**Sibling sessions.** A device of the head without a session to another device of the head sends it an
`intro`: a §6.7.2 v4 invitation from its own prekey to the target's bundle as listed in the head. The
target takes the session, with no person asked, only when the invitation's identity is a key of its head,
not tombstoned and not its own; its sender prekey equals that key's chain entry byte for byte; its first
message opens; its ephemeral key has not been seen; and no live session with that key is held. Anything
else is dropped, never filed. The origin forwards contact hellos (§5.9) to any device of its head it holds
a session with.

**A fork** (two different children of one parent, found by `verify_pair`, by `Sibling` against the head,
or by `Replay` for a link whose `prev` is a held link's hash and whose body differs from the held link at
that `seq`, the other branch verifying from that parent) freezes proposing and signing on every device
that sees it, until the person chooses a branch (`resolve`); devices on the other branch roll back to the
fork point and adopt the chosen links. Never tie-broken.

**A device that adopts a link tombstoning its own key** ends every link to the profile's devices,
tombstones them for repair (`J2527`) and drops held renewals (`J2545`). A key tombstoned in the chain is
never admitted again; a removed device receives the profile again only as a new device identity.

**Core additions** (step 1 of the design's plan): `meshcore_device_list_v2_sign(body, seed)`, which
refuses bytes that do not decode as a §5.8b body, and `meshcore_device_list_v2_changes(held, candidate)`,
which reports added keys with bundles, removed keys and changed bundles and judges nothing.

### 5.11 Wipe (`payloadType 11`)

A phone telling one of **its own linked devices** to erase itself (`DECISIONS.md` J320, J407). One
direction only, phone to device, under the session the link ceremony made. `body` is UTF-8 JSON:

```
{ "v": 1, "at": <unix seconds> }
```

A receiver acts on it **only when the sender is the phone that linked it** (`isLinkedPhone`), and
then destroys its store key, its identity keys and its files exactly as the distress code does, and
ends its process. A device that is not a linked device ignores it; so does a device receiving it from
any other contact. Nothing is acknowledged: the device that obeys has nothing left to answer with,
and a receipt would be a way to learn that a wipe landed.

**Best effort by construction, and the interface must say so.** A device that is off, offline, or
already erased never receives it; the mailbox drops it at the 30-day ceiling like any other message.
Removing the device from the phone (`J386`) is the reliable act — it stops new messages reaching the
device at once and needs nothing from it — and the wipe is the extra that may or may not arrive.

### 5.12 Reaction (`payloadType 12`)

One small emoji attached to one message (`DECISIONS.md` J403). `body` is UTF-8 JSON:

```
{ "v": 1, "to": "<messageId, 32 lowercase hex>", "e": "<one emoji, or empty to withdraw>" }
```

`to` names a message by the sender's `messageId` (§5.1a) — **whichever side sent it**, so a
reaction to one's own message and to the other person's are the same shape. `e` is a single emoji
from the set the apps offer, or the empty string, which removes whatever this sender had put there.

**One reaction per sender per message**: a second one replaces the first rather than adding to it.
A reaction naming a message the receiver does not hold, or one that has expired or been deleted, is
dropped silently — it is not an error and there is nothing to tell the sender. A reaction is **not a
message**: it does not change a conversation's sort order, its unread count, or its last line, and
it raises no notification.

Reactions ride the ordinary sealed envelope, so a relay sees one more envelope it cannot read and
cannot tell apart from a message.

### 5.13 Contact share request (`payloadType 19`) and disposition (`payloadType 20`)

`DECISIONS.md` J664 and J664a. **Sharing somebody else’s card asks them first**, and the approved
card then travels **from its owner**, so consent is inherent in the transmission rather than recorded
beside it.

*Implementation (`J3317-cardwire`, 3.089): `mesh-core-rs/src/card_share_wire.rs` reads and writes both
bodies (`vectors/67-card-wire.json`), in shadow beside the apps. Body at most 64 KiB, at most 256 card
fields, JSON nesting at most 64. No byte on the wire changes.*

Two types, never one with a flag. A request carries somebody’s card; a disposition carries a yes or
a no. A receiver that read one as the other would either hand out a card nobody approved or file an
approval as a card.

Both bodies are **UTF-8 JSON the apps own**, like the contact card (§5.7) and for the same reason:
what a card contains is a product decision that changes, and freezing it into the wire format would
make every field a protocol revision.

**Request** — requester → the card’s owner:

```
{ "v": 1,
  "rid": "<request id, 32 lowercase hex>",
  "to":  { "n": "<display name the requester knows X by>",
           "r": "<X’s published reach address: a handle or an E.164 number>" },
  "card": [ { "l": "<label>", "t": "<phone|email|handle|name>",
              "val": "<value>", "mine": true|false }, ... ] }
```

`card` is the card **as the requester holds it**, which is not the card its owner published. A card in
CHANNEL is two things stitched together: the fields its owner sent through `J84`, and the fields the
holder typed in himself from somewhere else. `mine` is `true` for the first and `false` for the second
— from `ContactField.fromPeer` (`J545`) — and it is the most important field here, because the
linkage that endangers the owner is usually one **she never published and cannot discover**. A prompt
that showed her card as she published it would ask her to approve a document that does not exist.

`to.r` is X’s **published** reach address and discloses nothing: a handle is one field its owner chose
to be findable by (`J665`), and a number reached this way is one its owner proved is theirs. The owner
sends her card there as an ordinary first contact (§5.7 over the invitation path), and **X accepts the
communication** the way any first contact is accepted — which is the second consent, and is already
built.

**Disposition** — owner → requester:

```
{ "v": 1, "rid": "<the request id>", "d": "approved" | "declined" }
```

**It never names a field.** The requester already knows every field on the card he holds, so naming
them would leak no data; what it would leak is the owner’s editorial judgement about his recipient,
which is the thing the design exists to leave with her.

**There is no “no answer” on the wire.** Silence is a state, not a message: a request may expire on the
requester’s phone to *no answer*, which fails safe and tells him something true, and it must never
expire to *approved* — that is the whole harm arriving through a side door — nor be transmitted as
*declined*, which would put words in her mouth.

A request names no field of X beyond what X published, and a disposition names no field of the owner
at all. Both ride the ordinary sealed envelope.

### 5.14 Location request (`payloadType 21`)

`DECISIONS.md` J759. **Ask somebody who is sharing their location with you for a fresh fix.** `body`
is UTF-8 JSON:

```
{ "v": 1, "session": "<the live share's session id>" | absent }
```

An absent `session` means *any share you are running with me*, which is what a viewer sends when it
has a bubble but not a session id to hand.

#### It needs nothing from the relay

This is a payload type rather than an endpoint on purpose. The request is an **ordinary sealed
envelope**, deposited under the recipient's token with their opaque wake id attached exactly like any
message — so the wake it causes is the one `J44` already grants to anyone who turned push on, and the
server learns nothing it does not learn when you say hello.

> *"If you share your location you're basically just sending a hidden message on a schedule to the
> person you're sharing location with, so it is no less secure than any other chat with that
> person."* — the owner, 14 September 2026

#### Answered only by a device already sharing, and silent otherwise

A recipient sends a fresh location token (§5.13's `loc:1;…` form) **only if it is already running a
live share with the requester**. Otherwise it does nothing at all: no answer, no error, no receipt,
no change in timing that a sender could measure.

That is a security property rather than an optimisation. A request that produced any observable
difference from a phone that is *not* sharing would be an *"are you there"* probe usable against
every contact somebody has, which is exactly the presence oracle the rest of this protocol spends
`destinationToken` rotation and unconditional heartbeats to avoid.

#### Rate

**At most one request per minute per conversation**, enforced by the sender of the request. The owner's
floor, and it is a scaling statement rather than a nicety: *"more than once a minute is too much and
doesn't scale well."* A fix is an envelope, and a relay carrying one per second per share is a
different product. A recipient that receives them faster is free to ignore the extras; it must not
answer faster than it would have.

Sent only while the viewer actually has the share on screen. A request from a backgrounded app would
be paying for an answer nobody is reading, which is the whole thing the 15-minute cadence exists to
avoid.

### 5.15 Call signalling (`payloadType 22`–`25`)

`DECISIONS.md` J886. Four types carry the setup of a voice or video call:

| | type | body |
| --- | --- | --- |
| `CallOffer` | 22 | the caller's SDP offer and the fingerprint of the media keys |
| `CallAnswer` | 23 | the callee's SDP answer, sent only after a person accepts |
| `CallCandidate` | 24 | one ICE candidate, or a small batch, from either side |
| `CallEnd` | 25 | declined, hung up, cancelled, or failed — the reason is in the body |

**Media never travels here.** Audio and video go by RTP directly between the two phones, or through
a relay that cannot read them. What travels on this path is the few kilobytes of setup.

#### 5.15.1 Why the signalling is an ordinary payload, and why that is the security property

An offer is sealed, authenticated and addressed by the pairwise session that already exists —
byte for byte the same treatment a text message gets. There is no separate signalling channel and no
call-specific key exchange with the server.

That is not an economy. It is the point:

> **The fingerprint of the media keys travels inside a message only these two can read.**

WebRTC, used as it ships, performs its own DTLS-SRTP key exchange between two endpoints that this
protocol's pairing has never authenticated. A call built that way is end-to-end encrypted *to
whoever answered*, and anybody who can intercept or substitute the signalling **is** the far end.
The call would look encrypted and protect nothing.

Binding the DTLS fingerprint to the pairwise session closes that, and it closes it with no new
cryptography: the fingerprint is just another field in a payload the existing session already
protects. `CLAUDE.md`'s rule against inventing cryptography is satisfied by composition rather than
by exception.

#### 5.15.1a The binding rule, stated as a requirement

`J887`. The offer and the answer each carry the fingerprint of the DTLS certificate their sender
will present. Both bodies carry it under `fp`, as the `sha-256 AB:CD:…` form SDP already uses.

> **A receiver MUST compare the fingerprint presented in the DTLS handshake against the `fp` it
> received in the sealed payload, and MUST abandon the call if they differ.**
>
> It MUST NOT fall back to the fingerprint in the SDP body. That value and the one in `fp` come from
> the same place when nothing is wrong, and from different places when something is.

**That single comparison is the whole of what this protocol adds to WebRTC**, and leaving it out
produces a call that is encrypted, that reports itself as encrypted, and that an attacker who can
reach the signalling is one end of. There is no symptom. Both phones show a connected call.

Three things follow, and each exists because it is the way this gets quietly skipped:

- **A call with no `fp` is refused**, not treated as unauthenticated-but-acceptable. An absent field
  must never be the easy path.
- **`fp` is compared against what DTLS actually presented**, not against what the SDP says it will
  present. Checking the body against the body is a check that cannot fail.
- **The comparison happens before any media is rendered**, not on first audio. A voice heard for a
  second before a mismatch is noticed has already defeated the point.

The reason this can be a *requirement* rather than an aspiration is §5.15.1: `fp` arrived inside a
payload the pairwise session already authenticated. Nothing new is trusted to make the rule true.

#### 5.15.1b Body shapes

*`J3317-callwire`: the two shipped spellings are both valid and read by both: sorted keys with `/` written `\/`, and a fixed order with a raw `/`. `vectors/66-call-wire.json` holds the exact bytes of each and the grammar a reader may rely on: plain JSON with integers written canonically, no duplicate key whose values differ. A writer must stay inside it, because the two shipped readers disagree outside it.*

Opaque to the core (§5.15.2), and specified here so the two apps agree. All four carry `v` for the
version of this shape, so a later change is detectable rather than a parse that half-succeeds.

```
CallOffer      { "v": 1, "sdp": "<offer SDP>", "fp": "sha-256 AB:CD:…", "media": "audio" }
CallAnswer     { "v": 1, "sdp": "<answer SDP>", "fp": "sha-256 AB:CD:…" }
CallCandidate  { "v": 1, "mid": "0", "mline": 0, "c": ["candidate:…", …] }
CallEnd        { "v": 1, "reason": "declined|hungUp|cancelled|unanswered|busy|failed" }
```

`media` is `"audio"` today. **It is present from the first version rather than added later**, because
a receiver that cannot tell an audio call from a video one has to guess which to show while it rings,
and `J834d` puts audio first precisely so that video is a later addition to a finished machine rather
than a second machine.

`reason` is the same closed set the apps' `CallSession.Ending` holds, so the words in a conversation
entry and the word on the wire are one list rather than two.

#### 5.15.1c One call, one packet in each direction

*Added 19 September 2026, on the owner's instruction, and it supersedes the behaviour the two apps had
shipped until then: "Nonsense to send multiple call packages to wake up the other end. Exactly one
should be plenty in either direction as all the extras create problems."*

**A call setup is two envelopes and an ending is one.** `CallOffer` goes from the caller,
`CallAnswer` comes back, `CallEnd` closes it from whichever side hangs up. Nothing else is sent, by
either app, for a call that is behaving.

Two practices are therefore forbidden, and both were shipped:

- **No repeat of the offer.** A caller sent the same offer every three seconds for forty-five, so a
  single unanswered call produced eight or nine envelopes, each competing with the first for the
  same mailbox, the same push budget and the same ring window. A call that is not answered ends and
  says so; it does not try again quietly.
- **No trickled candidates.** Each address gathered was sent as its own `CallCandidate` envelope,
  which is where most of the traffic in a failed call came from. **Candidates travel inside the
  SDP**: the sender waits for gathering to finish, or for two seconds, and sends the local
  description as it then stands. There is no wire change for this — an SDP with `a=candidate:` lines
  in it is an ordinary SDP, and a receiver applies it with `setRemoteDescription` exactly as before.

`CallCandidate` **stays in the protocol and stays accepted**. A peer running a build from before
this rule still trickles, and refusing its candidates would break calls that work today.

**Amended 20 September 2026 (`J1218`): one supplementary packet, and only one.** The sentence that
stood here said *"nothing this project ships will send one"*, and the consequence underneath it was
not seen: a description leaves at the ceiling, so **an address gathered after the ceiling was never
sent at all.** For two phones on different networks that address is the relay candidate -- the
slowest of the three to obtain, since it costs a TURN Allocate with a 401 and a nonce in the middle,
and the only one that works when neither phone can be reached directly. `J1181` had already noticed
the hole and paid for it in time, extending the ceiling to six seconds while no relay candidate had
appeared, which made every call between networks four seconds slower to answer and still lost
anything arriving at 6.1 seconds.

So each side may send **at most one** `CallCandidate` per call, carrying every candidate gathered
after its description went, sent once and never repeated. It goes when the first relay candidate
appears or when gathering completes, whichever is first, and it is **not** sent once the call is
bound, or for a call this phone is no longer on.

This is not the practice the rule forbids. What the owner ruled out was the flood -- *"all the extras
create problems"* -- an offer repeated eight or nine times and one envelope per address, every one
of them competing for the same mailbox, push budget and ring window. One packet per side, carrying
what the description could not, deposited without a push (`J1093`'s *"deposit it, ring nobody"*),
is the opposite trade: it exists to stop a call waiting, not to wake anybody twice.

**The direction of the change is what makes it safe.** Every build in the field already reads
`CallCandidate` and has for months -- the receivers were never removed -- so this adds a writer to
an installed reader, which is the ordering `CLAUDE.md` records the cost of getting backwards.

The ceiling is unchanged by this entry, deliberately: two things in the call path must not move at
once while the owner's calls are still under investigation. Shortening it back to two seconds is a
separate change, and the evidence for it is a trace carrying a `candidates after the description`
line.

**Amended 23 October 2026 (`J3322`): up to three late envelopes, and only on a slow gather.** A call over
a thin link (the owner's call to Tenerife) gathers its reflexive and relay addresses seconds apart, and
one envelope spent on the first of them left the rest unsent. So a side whose gather is *slow* -- this
gather shows a STUN or TURN timeout, or the description left with neither a reflexive nor a relay
candidate, or the previous gather within ten minutes was slow -- may send **at most three**
`CallCandidate` envelopes per call, one on each new reflexive or relay candidate and one when gathering
completes, each carrying only what was gathered since the last; it also waits for a relay candidate up
to six seconds in all, instead of three. A gather that is not slow keeps the single envelope of the
paragraph above, flushed on a relay candidate or at completion. Every other condition stands: no
envelope once the call is bound or for a call this phone is no longer on, no push (`J1093`), and no
timer-driven repeat. The constants are `slowLateEnvelopes` / `SLOW_LATE_ENVELOPES` (3) and
`slowRelayCeilingMillis` / `SLOW_RELAY_CEILING_MILLIS` (6000) in `CallGatherWait`, held equal by
`check-shared-tables.py`.

**No capability is needed, and no older build is cut off.** A receiver applies every `CallCandidate`
whose call id matches the live call, buffering it while the call is ringing or has no media yet, and
keeps no count of envelopes per call; so a build from before this amendment (including 3.049, which
sent at most one) takes a second or third envelope exactly as it takes the first. The extra envelopes
can only add candidate pairs. The receivers' existing bounds still apply (64 candidates per envelope,
256 buffered in all).

**Why two seconds.** Gathering on a phone with Wi-Fi and cellular usually completes in a few hundred
milliseconds; the ceiling exists for the case where a server is slow to answer, and a call whose
setup waits longer than that has lost more to the wait than it gains from the last candidate. The
ceiling is a constant on both platforms, and `check-shared-tables.py` holds them equal.

**Where a candidate policy is applied.** The *Calls over Wi-Fi* switch and the relay-only setting
both decide which addresses may leave the phone. With trickled candidates that decision was made per
envelope; with candidates inside the SDP it is made on the description before it is sent, by removing
the `a=candidate:` lines the policy refuses. The rule is unchanged, including its safeguard: if the
policy would remove every candidate, none are removed, because a call with no addresses cannot
connect and a silent failure is worse than the exposure the switch was avoiding.

**One exception, for glare (`J1853`, 1 October 2026).** When both people press Call at once, each
phone receives the other's `CallOffer` while offering, and the rule in `CallSession` keeps the
offer of the numerically higher identity. The phone whose call survives sends its own `CallOffer`
**once more**, byte for byte the same: the crossing offer proves the other phone is reachable at that
moment, and a phone that already received the first copy ignores the second as a repeat. Once per
call, never on a timer. The phone that gives way does not ring: its person pressed Call on that
contact seconds earlier, so it answers the surviving offer itself, unless the profile is locked.

#### 5.15.1d What may wake a phone

*The same instruction, for messages rather than calls: "Only messages that create a line in the chat
should make a sound or show a banner."*

One table, applied by both apps when they deposit and by nothing else:

| Payload | Treatment |
| --- | --- |
| `text` (1), `groupMessage` (13) | **May announce.** A banner and a sound. |
| `callOffer` (22) | **May ring.** Once per call, and never a banner. |
| everything else | **Silent.** Woken, not announced. |

That is receipts, reactions, attachment slices, cards, device lists, syncs, wipes, roster traffic,
location requests and updates, thread history, and the other three call types. A photograph arriving
announces itself **once**, through the `text` envelope that carries its message line — which is sent
before the slices, so the conversation shows it arriving rather than appearing when the last slice
lands.

The relay sees only a flag (§`deposit`'s `ring`, `urgent`, `prompt`) and never the payload type, so
this table lives in the apps. `PayloadKind.deservesABanner` on iOS and Kotlin is its implementation,
and `check-shared-tables.py` holds the two lists identical.

#### 5.15.1e Moving a live call off the relay (`J2834`, 15 October 2026)

A call that connected through the relay (route **T**) stays there for its whole length unless the
two phones look again. A call answered from a closed app often sets up before a direct path is
known, so this is common, and the relay is the worst of the three paths for cost, quality and
privacy. An ICE restart looks again: fresh ICE credentials, a fresh gathering, and the existing
path carries the call until a better pair is selected.

**It needs one more offer and answer in the middle of a call, and nothing else.** No new payload
type: the restart reuses `CallOffer` (22) and `CallAnswer` (23), sealed by the same pairwise session,
with one added field.

```
CallOffer   { "v": 1, "sdp": "<offer SDP with new ice-ufrag/ice-pwd>", "fp": "sha-256 AB:CD:…", "media": "...", "restart": n }
CallAnswer  { "v": 1, "sdp": "<answer SDP>", "fp": "sha-256 AB:CD:…", "restart": n }
```

`restart` is a positive integer, 1 for the first restart of this call, rising by one for each.
Absent or 0 means an ordinary setup, exactly as before.

**Rules, each one a way this goes wrong if left out:**

- **Only to a card that carries `cir`.** A build without this section reads a `CallOffer` during a
  call as a second call: busy, or worse, a ring. The capability is the per-contact half of the
  reader shipping first. Builds that read it advertise `cir`; a sender that does not see it never
  restarts.
- **Only the side that sent the call's first `CallOffer` restarts.** The other side never initiates,
  so two restarts cannot cross. A receiver that is itself waiting for a restart answer and receives a
  restart offer discards the offer.
- **Never when the call's reach is relay-only** (`J1002`), whichever person chose it. A restart under
  relay-only would gather host and reflexive candidates and send them, which is the exposure the
  setting exists to prevent. The candidate policy of §5.15.1c applies to the restart description
  exactly as to the first.
- **The fingerprint does not change, and is checked again.** An ICE restart does not repeat the DTLS
  handshake, so `fp` in a restart offer or answer MUST equal the fingerprint bound at setup
  (§5.15.1a). A restart body whose `fp` differs is discarded and the call continues on its current
  path; it is never adopted, and it never re-binds the call.
- **Only for the call that is live, from its peer.** A restart offer is accepted only while this
  phone is in a connected call with the sender, and `restart` must be greater than any restart number
  already answered in that call. Anything else is discarded silently. A restart never rings, never
  announces, and is deposited without a push (§5.15.1d: silent).
- **Bounded.** At most `maxIceRestarts` per call, the first no sooner than `iceRestartSettle` after
  connect, and two never closer than `iceRestartGap`. A restart is attempted only while the selected
  candidate pair is relayed (`relay` on either end); one whose answer has not arrived within
  `iceRestartAnswerWait` is abandoned and the call goes on as it is.
- **Nothing is torn down.** The current transport carries media until the new pair is selected.
  A restart that fails leaves the call exactly where it was.

The route letter is re-read from the selected pair after every change, so **T** becomes the direct
letter when the move happens.

#### 5.15.1f Peer facts: an optional `pf` block (`J3323`, 23 October 2026)

`CallOffer`, `CallAnswer` and `CallEnd` (and the ICE-restart offer and answer of 5.15.1e) may carry one
more key, `pf`, an object of facts the sender reports about itself so that either phone's
performance report can show the other end. **It adds no envelope**: 5.15.1c stands unchanged, and the
mid-call update is the restart pair, not a packet of its own.

```
"pf": { "pv": 1, "a": "3.049", "b": 1760, "p": "i", "o": "26.1", "n": "w", "v6": 1 }          offer, answer
"pf": { "pv": 1, "rt": [48, 410], "ls": 120, "fr": [14, 22], "fz": [3, 42], "cc": 35,
        "qs": 2, "rp": "u", "at": 60 }                                                       end, restart
```

`a` app version and `o` OS version are `0-9` and `.` only, at most 12 bytes; `p` is `i`, `a`, `m` or `w`;
`n` is `w` Wi-Fi, `c` cellular, `e` wired, `u` unknown; `v6` is 0 or 1; `rt` round trip average and worst
in ms; `ls` loss in tenths of a percent; `fr` video frames per second received and sent; `fz` freeze
count and total freeze time in tenths of a second; `cc` audio concealed in tenths of a percent; `qs` how
many times the sender's quality supervisor cut or stopped video; `rp` is `u`, `t`, `s` (relay over udp,
tcp, tls) or `n` (direct); `at` seconds into the call. The whole value is at most 120 bytes, every number
is clamped to a stated range, and there is no free text.

**A reader drops what it cannot read, and never refuses the call over it.** A `pf` that is not an
object, is over 120 bytes, has another `pv`, or is empty after validation is ignored; a single bad field
is dropped alone; unknown keys are ignored. Older builds already ignore the key. The values are
**display only**: they never reach routing, trust, the quality supervisor or any screen but the
report (one exception, `J3663`: an end's `at`, the sender's own count of seconds into the call, is also what the receiving phone
records as the call's length when it is positive and below its own count, `call_rules` op 25, so both ends log the same length), where they are labelled as reported by the other phone, and they are held in memory for the
current and previous call and never stored. Not sent: carrier, SSID, addresses, location, whether a VPN
is on, anything about another contact or call. Group call legs neither send nor keep it. The shared
fixture is `vectors/call-peer-facts.json`.

#### 5.15.2 The bodies are opaque to the core

Like a contact card (§5.7), each body is UTF-8 JSON the apps own and the core carries without
parsing. **Deliberate, and the reason is the media stack rather than laziness:** SDP and ICE
candidate syntax follow whatever WebRTC build an app ships, and those change. The wire format must
not change with them. What the core guarantees is the envelope — that an offer is from who it says,
to whom it says, unmodified, and unreadable by anything in between.

The core does enforce the one structural rule: a call type carries **no group fields**
(`PayloadType::is_call`, and §5.2.3's "group keys appear on group types and nowhere else" applies in
both directions).

#### 5.15.3 Trickle, and why candidates are their own type

Candidates are sent repeatedly, by both sides, for as long as gathering continues, rather than
waiting to put a complete set in the offer. That is what makes a call connect in about a second
instead of after the slowest candidate has been found.

On this transport there is a second reason. A candidate may reach the other phone by a **different
rung of the ladder** than the offer did — an offer that went by the mailbox and a candidate that
went straight over Bluetooth are both ordinary deliveries, and neither is waiting on the other.

#### 5.15.4 One end type, and what it has to do with a push

`CallEnd` covers declined, hung up, cancelled-before-answer and failed. One type, four reasons,
because the receiving app does the same thing with all of them: end the call it is showing.

**It matters that a hangup is a payload and not a second push.** On iOS a VoIP push must be reported
to CallKit as an incoming call the moment it arrives, or the system terminates the app — so a
"cancel" delivered as a second VoIP push would **ring again** rather than stop the ring. The first
push has already woken the app; the hangup arrives on the connection that is now open, and the app
ends the call locally. `DECISIONS.md` J834c has the full reasoning.

#### 5.15.5 What a relay can see, stated plainly

If both phones can reach each other directly — same room, same network — nothing about the call
touches a server at all.

When they cannot, the media is relayed. A relay **cannot read the call**: the audio and video are
encrypted with keys it never has, bound to the fingerprint exchanged above. What it can see is the
two addresses, that they are exchanging a live flow, when it started and stopped, and roughly how
much passed through. That is a call detail record in everything but name, and `LEGAL-RISK.md` §9 is
where the consequences of holding one belong.

**Nothing in this section is implemented in either app.** The types exist so that a call confined to
a local network and one that crosses the internet are the same code with different candidates,
rather than two implementations — `DECISIONS.md` J834d.

#### 5.15.6 Small group calls — a mesh of ordinary legs (`J1558`)

A group call is **every pair of its participants on an ordinary §5.15 call** -- one `CallOffer`, one
`CallAnswer`, one DTLS fingerprint bound by §5.15.1a, per pair. There is no media server, no group
key and no new cryptography: a four-person call is six two-person calls that one screen shows as one.
Each phone sends its camera and microphone once per other participant, which is why the size is
capped: **4 people with video, 8 with voice only.** A forwarding server is the later step for larger
calls, and a paid one (the owner, 26 September 2026).

**Wire.** Four optional fields on `CallOffer`, ignored by a build that
does not know them -- which then sees an ordinary call from whoever offered, and talks to that one
person. Nothing breaks; that phone simply is not in the rest of the mesh.

```
"gc":   "<32 hex>"          the group call's id, chosen by whoever starts it
"gt":   "<32 hex>"          the group thread it belongs to (§5.2 groupId), when there is one
"gp":   ["<64 hex>", ...]   every participant's identity key, the starter's included, at most 8
"join": true                this leg joins a call its receiver is ringing for or is already on:
                            fold it in, never ring for it separately
```

**Who offers to whom, so every pair gets exactly one leg:**

1. The **starter** offers to every other participant. Those offers ring (`join` absent).
2. A participant who **accepts** answers the starter's leg, then offers (`join: true`) to every
   participant whose identity key sorts **after** its own, **the starter excepted**.
3. A `join` offer for a call its receiver is on is answered at once; for a call it is still ringing
   for, it waits and is answered when that person accepts; for a call it declined or has left, it is
   ended with `declined`. A join that arrives **before** the offer that rings its receiver for that
   call -- two senders, two routes, two speeds -- is kept for up to 60 seconds for that offer, and
   answered on accepting like any other held join; if the offer never comes, it is ended with
   `declined`. Declining it at once would lose that pair for good whenever the joiner sorts first,
   because nobody else would ever offer to it. A held join whose sender sends `CallEnd` is dropped.
   A phone that declines, hangs up, or stops ringing because the starter left ends every join it
   is still holding with `declined`.

Two non-starters therefore meet exactly once whichever of them joins first: the lower one's offer is
waiting for the higher, or reaches it already on the call. A receiver refuses a group offer that
names more than 8 participants, or more than 4 with video, or does not include both the sender and
the receiver in `gp`.

**A group offer rings only for a group its receiver holds.** A ringing offer whose `gt` is not a
group thread on the receiving phone, or whose `gp` names anybody outside that thread's roster, is
ended with `declined` and never rings. Otherwise any contact could make a phone offer, on accepting,
to people it never grouped with that contact, by listing them. A receiver already on a call -- a
one-to-one call or another group -- ends a new offer with `busy`.

**Leaving** is `CallEnd` on each of this phone's legs. The call ends for a phone when its last leg
ends; it ends for everyone when every leg has.

### 5.16 Thread history (`payloadType 26`)

`J943a`. **A phone's own message history, for one of its own linked devices.** One direction only,
phone to device, under the session the link ceremony made — the same shape as profile sync (§5.10),
for the same reasons, and a receiver applies the same rule: **accepted only from the phone that
linked it**, and from nothing else.

It exists because a linked device that starts empty cannot continue a conversation. The owner, 17 Sep 2026:
*"a conversation can't very well be started on one and finished on the other with the complete thread
on each."*

```
{ "v": 1,
  "thread": "<contact identity key, hex>",
  "seq": <uint>, "of": <uint>,
  "messages": [
    { "id": "<16 bytes, hex>",
      "out": <bool>,
      "at": <uint, seconds>,
      "ms": <uint, 0-999>,                                         // optional (J2538)
      "ai": <uint, 0-16777215>,                                    // optional (J2555)
      "text": "…",
      "media": { "kind": <uint>, "bytes": <uint>, "name": "…" }   // optional
    }, … ] }
```

**`ms` (`J2538`): the millisecond part of `at`.** It is the message's stored `sentAtMillis`, the same
value payload key 19 carried on the wire (§5.1b), so that messages written inside one second — which
tie on `at` — keep their send order when they are mirrored to the profile's other device or filed from
a full history. Optional and additive, in version 1 and in the version 2 group lines alike: a writer
omits it when the message has no stored `sentAtMillis`; a reader that does not know it ignores the key
(every reader skips unknown keys, so no older build refuses a batch for carrying it, and **there is no
writer switch**); an absent or out-of-range value (above 999, or negative) is read as ABSENT — the
batch is still applied. A receiver files each message by the §5.1b rule (`SendOrder.filesAtOrBefore` /
`insertionIndex`): the lower `ms` goes first when both messages carry it and share a second, and
otherwise the order is the one before `J2538`, arrival order inside a tie.

**`ai` (`J2555`): the AI colour of the message.** The message's stored `aiAuthor`, the same 24-bit
`0xRRGGBB` value payload key 16 carries (§5.28), so a message an AI wrote shows in that AI's colour on
the profile's other device from the first copy that files, not grey until a later copy corrects it. It is
written by the origin's forward of a received message, by the sent mirror and by the full history, in
version 1 lines, and only while `J1960`'s `writesAIAuthor` switch is on. Optional and additive like `ms`
(a reader without it ignores the key; no older build refuses a batch for it). A reader files the value
through `AIColour.snapped` (hostile input never draws as given, §5.28), and an absent or out-of-range
value (negative, above `0xFFFFFF`) is read as ABSENT with the batch still applied. **Set, never clear:** when
a later copy of a message already filed carries a colour and the filed one has none, the filed one takes
it; a copy without a colour never removes one, and a second colour never repaints the first. (Version 2
group lines do not carry it.)

**`seq` and `of` are a progress report, not a reassembly aid.** Each payload is independently
applicable and may be applied in any order or not at all; the pair exists so the receiving device can
show *"12 of 40"* while it fills, and so a transfer that stops partway is visibly partial rather than
silently short. A receiver that never sees every `seq` is in a legitimate state.

At most **fifty** messages per payload; a longer thread is sent as several. Bounds, applied
**before allocation** and to the encoded bytes rather than to a declared length: `of` at most 4096,
`messages` at most 50, `text` at most 4096 bytes, `name` at most 255 bytes, `thread` exactly 64 hex
characters, `id` exactly 32 hex characters. A payload violating any of these is refused whole, never
truncated to fit — the rule §5.10 already applies to a device list.

#### What does not travel, and why each one is deliberate

- **Notes never travel.** Already true of §5.10 (`J324`), and true here for the same reason.
- **One full pass, and a repair only when the device asks.** This is sent once, when a device first
  answers with its bundle; later messages reach the device live, not through this. The owner, 17 Sep
  2026: *"it's ok that history doesn't come except what's app says at linking keep the phone on/near
  at link time … and then no more sync after that."* A device that lost part of that first pass asks
  for it with §5.16a (`J2198`), which re-sends the conversations it is short of through this same
  payload; nothing else is reconciled.
- **Attachments above `LARGE_MEDIA_THRESHOLD_BYTES` (2 MiB) are not sent** (`J943b`). The `media`
  object travels — kind, size and name — and the bytes do not, so the receiving device draws a
  placeholder that knows what it stands for. Anything at or below the threshold is sent as ordinary
  attachment chunks (§5.6) against the `id` this payload names, so it simply arrives.

  **The reason is a measurement, not a preference.** There is no server copy to fetch from later
  (§5.6's mailbox holds an envelope until collection and no longer), so every byte of history has to
  come from the phone over the link, and a link that takes an hour is a link nobody completes.

#### Why a placeholder is a state and not an absence

A placeholder has **three** states, not two: not fetched, fetching, and **gone** — because the phone
may no longer hold the bytes. A control that silently does nothing when tapped is the failure this
protocol's own §5.6 `attachment missing` (type 18) exists to prevent, and the same reasoning applies
here. `J943c` (§5.17, below) records the redemption path; it is not part of this section.

#### The one thing a receiver must not infer

**A thread that arrives empty is not a thread with no messages.** It is indistinguishable from a
transfer that was interrupted, a phone that had cleared that conversation, and a thread that really
is empty. A receiving device therefore never deletes local content on the strength of an absence
here, and never reports a thread as complete — only as *filled to what arrived*.

### 5.16a Asking the phone for what a device is short of (`payloadType 26`, version 5)

`J2198`. **A device of a shared profile asks the phone it came from to check its history, and the
phone re-sends the conversations that are short.** It reuses the completeness check a Transfer runs
(`J1857` G, `TransferWire`): thread-history version 5, under the session the link ceremony made, in
both directions. No new payload type, no new key, nothing cryptographic.

```
device -> phone   { "v": 5, "transfer": "<16 bytes, lowercase hex>", "step": "heal" }
phone -> device   { "v": 5, "transfer": "<same>", "step": "check", "seq": n, "of": m,
                    "thread": "<base64, 32 or 16 bytes>", "ids": "<base64, 16 bytes each, at most 8000>" }  ...
phone -> device   { "v": 5, "transfer": "<same>", "step": "checkdone", "threads": k }
device -> phone   { "v": 5, "transfer": "<same>", "step": "checked", "missing": ["<base64 thread>", ...] }
```

- `transfer` is a fresh random request id per request. `check`, `checkdone` and `checked` are the
  Transfer messages unchanged; `heal` is the one new step.
- **Only the origin answers** (the phone the device was linked from), and not while a Transfer is in
  flight. It answers once no history walk to that device is still running, so a batch in flight is
  never counted missing.
- A thread's ids are the walk's ids: a received one-to-one message under its sender's id, a sent one
  under its own. The device reports a thread short when any id is neither a message id nor a sender's
  id it holds. A check that did not arrive whole is not answered.
- On `checked`, the phone re-sends §5.16 batches **for the missing threads only**. The device files
  each message once (by its id, and a received message also by its sender's id), so a re-sent message
  it already holds adds nothing. Their attachments are not re-sent by the phone: the device redeems
  its own unfilled placeholders from the last seven days (§5.17), which cannot re-send one it holds.
- The device asks by itself once per profile, no sooner than an hour after it was linked, and once
  more ten minutes after a short answer, at most three times in all; it also asks when the person
  chooses to.
- **A copy that stopped filling (`J3460`).** The device also asks again by itself when a conversation's history stays incomplete
  (fewer §5.16 parts noted than the phone declared) with no part not seen before for six hours, no sooner than six hours after its
  last request of any kind, not while a request is in flight, and at most four such automatic re-asks per profile (the person's own
  request is not counted). The decision is `channel-algo` `history_rules` op 22 (`StaleReask`); the wire is the same `heal` body, so
  an older phone answers it exactly as it answers any other. A conversation that is complete never causes one.
- **Older readers.** A phone on a build before `J2198` refuses a `heal` body whole (an unknown step
  parses as nothing, and no version-1 to version-4 reader accepts version 5), so it says nothing; the
  device shows that it is waiting. A device on such a build never sends `heal`.

### 5.16b What became of a write-through message, and asking for a re-introduction (`payloadType 26`, version 6)

`J2197b`. A device of a shared profile that holds no keys for a contact writes to it through the phone
(`J1997`, §5.16 version 4 with `to`). Version 6 carries the two things that loop lacked, under the session
the link ceremony made, in the direction shown and accepted from nobody else:

```
phone -> device   { "v": 6, "st": [ { "id": "<16 bytes, lowercase hex>", "d": <uint seconds>, "r": <uint seconds>,
                                       "via": <route code>, "arr": <route code> }, ... ] }
device -> phone   { "v": 6, "intro": "<contact identity key, 64 lowercase hex>" }
```

- **`st`, phone to device.** For a message the phone sent because this device asked (a `posted` answer to a
  version-4 request with `to`), the phone remembers which device asked (the last 500) and, whenever its
  copy gains a route or a receipt, tells that device: `id` is the id the device's copy carries (the
  answer's `id`), `d` when it was delivered, `r` when it was read, `via` the route that took it from the
  phone and `arr` the route the contact's receipt named. Route codes are the receipt's own table
  (the optional 25th byte of a receipt body, `J1530`: 1 Bluetooth, 2 Wi-Fi Direct, 3 local network,
  4 Internet, 5 text bridge, 6 IPv6); an
  unknown code is read as absent. Every key but `id` is optional. A repeated ask is answered with the
  current status too. At most 200 entries; refused whole on a bad or repeated `id`, a time below 0 or past
  the year 9999, an empty list, or a body that also carries `intro`. The device applies an entry only to
  its own outgoing one-to-one message with that id: a time and `arr` are written once (read implies
  delivered), `via` follows the phone's.
- **`intro`, device to phone.** The device holds no keys for this contact. It asks after it has written
  through the phone ten minutes or three times, then at most hourly, doubling to daily, and forgets the
  contact once it holds its keys. The phone acts only for a device it linked, only for a contact it is
  paired with (not blocked, not an AI, not a link end, not met only in a group), at most once an hour for
  one device and one contact: it sends that contact its §5.8 device list with `again` naming the device.
  Nothing new is trusted -- the contact applies the person's own list by the rules it always has.
- **Older readers.** No version-1 to version-5 reader accepts version 6, so an older phone ignores the ask
  (the device keeps writing through it, as before) and an older device ignores the status.

### 5.16c A message deleted on one device of a shared profile (`payloadType 26`, version 8)

`J2769`. Under the link session, between a profile's own devices only (routed like a read note: a device
writes to its phone, the phone writes to its devices and passes a device's note on, only what it removed):

```
{ "v": 8, "del": [ { "id": "<16 bytes, lowercase hex>", "from": "<sender identity key, 64 lowercase hex>" }, ... ] }
```

`from` absent: a message this profile sent, `id` its own id. `from` present: a message received, `id` the
sender's id and `from` the sender. 1 to 200 entries; refused whole on a bad id, a repeat, or any other
version's shape. The receiver removes the matching row with its attachment and transcript, ignores an
unknown or already-removed entry and passes on nothing for it. Not sent for a timed or view-once message.
No new payload type; every older reader refuses a version-8 body whole.

### 5.16d A chat deleted, archived, pinned or muted on one device of a shared profile (`payloadType 26`, version 8, `chats`)

`J3601`. The version 8 body (5.16c) may carry an optional list `chats`, routed the same way (a device writes to its phone; the phone
writes to its devices and passes a device's note on, only what changed here, never back to its source). A body that carries only chat
notes sends `"del": []`, which every reader older than 3.081 refuses whole.

```
{ "v": 8, "del": [], "chats": [ { "t": "c|g|m|o", "k": "<key>", "s": <1-7>, "at": <ms>, "d": "<device id, 16 lowercase hex>" }, ... ] }
```

`t` and `k` name the chat as every device of the profile does: `c` and the contact's identity key (64 lowercase hex), `g` and the
group id (2 to 128 lowercase hex), `m` and an email-only card's normalised address (at most 254 bytes), `o` and an empty `k` for notes
to self. `s`: 1 delete, 2 archive, 3 unarchive, 4 pin, 5 unpin, 6 mute, 7 unmute. `at`: the act's clock in milliseconds, 1 to
2^53 - 1 (the writer's `max(now, newest clock it holds for the chat + 1)`). `d`: the 8-byte short id of the device that acted, the tie
break. 1 to 200 entries; refused whole on a bad entry, an unknown `s` or a repeat.

Merge (`channel-algo` `chat_state_sync`): archive, pin and mute are registers, the higher `at` wins and an equal `at` goes to the larger
`d`; a repeat changes nothing. A delete keeps the later `at` as the chat's tombstone and removes every message written at or before it;
the chat goes when nothing newer is left (a group is left, as a delete made there). A COPY of a message (history, heal, transfer, sent
mirror, received forward, profile item, group history) written at or before the tombstone is not filed and does not re-create the chat;
a live message always is, and unarchives (its device sends `s: 3`). Each device keeps the ledger 90 days, at most 1000 chats. No new
payload type.

### 5.17 Redeeming a history placeholder (`payloadType 27`, `28`)

`J943c`. §5.16 leaves a placeholder wherever an attachment was too large to cross the link. This is
how a linked device asks for one, and how the phone answers when it cannot.

*Implementation (`J3317-redemptionwire`, 3.089): `mesh-core-rs/src/history_redemption_wire.rs` reads and
writes the request body (`vectors/68-history-redemption-wire.json`), in shadow beside the apps. Body at
most 512 bytes; `thread` is exactly 64 and `id` exactly 32 lowercase hex characters. No byte on the wire
changes.*

The owner, 17 September 2026:

> *"if a user hits a placeholder for a graphic or file that was not transferred on backfill, the
> linked device could ask the phone (master) to send the file and the master could que it as
> background task if it still has a copy."*

**Device to phone (`27`), phone to device (`28`), and nothing else may use either.** Both travel
under the session the link ceremony made, the same channel §5.16 used.

```
27  { "v": 1, "thread": "<contact identity key, hex>", "id": "<16 bytes, hex>" }
28  { "v": 1, "thread": "<contact identity key, hex>", "id": "<16 bytes, hex>" }
```

Bounds before allocation: `thread` exactly 64 hex characters, `id` exactly 32, the whole body at
most 512 bytes. Refused whole otherwise.

#### The answer is one of three things, and silence is not one of them

- **The bytes**, as ordinary attachment chunks (§5.6) against the `id` this request names — exactly
  what §5.16 sends for a small attachment, so the arriving file lands on the message it belongs to
  rather than as a new one.
- **`28`**, meaning *I no longer hold it*. A separate type because a placeholder that stays
  unchanged is indistinguishable from one whose request is still in flight, and the owner's design is
  explicit that *"if it still has a copy"* is a real branch rather than a caveat.
- **Nothing**, when the requester is not one of this phone's own linked devices. That silence is
  deliberate and is the same shape §5.14 uses for a location request from somebody not sharing: an
  answer that differed from the not-permitted case would confirm the message exists.

#### Which id names the chunks, and the device that asks by itself (`J2198`)

- **The chunks are named from the id the device filed the message under** -- a received message's
  SENDER id, a sent one's own (§5.16) -- through each platform's attachment identifier: the first
  16 bytes of SHA-256 over the id as a UUID string. iOS writes that string in capitals and Android in
  lower case, so a device matches **either** when filling a placeholder. Before `J2198` a received
  message's chunks were named from the phone's LOCAL id, which no device placeholder carries, and the
  phone matched a request against local ids only; both now accept the sender id too. No byte on the
  wire changed shape: a phone on an older build still names its chunks as it did, and its readers
  ignore what they cannot match.
- **A device asks for a mirrored photo by itself.** The phone's one-message mirror of a message sent
  or received after the link (§5.16, `forwardReceivedToOwnDevices`, `mirrorSentToOwnDevices`)
  carries only the description. The device sends `27` for it: at once when the message was sent from
  the phone or the device holds no session with the contact (nobody else will send the bytes), and
  after 90 seconds otherwise (the contact's own copy, sealed to this device, usually fills it first).
  Asked again after ten minutes while unfilled, three times at most. Only within the last seven days.
- **The share's window.** The first pass sends photos and videos -- with the text (§5.16) and as the
  back-fill that follows -- for the days chosen at share time, never fewer than seven. Older ones stay
  placeholders, redeemed here on a tap. Bounds on what travels unasked: the last seven days always;
  no single attachment over 500 MiB; beyond the seven days, only while the plan's 4 GiB has room. A
  Transfer (`J1857` G) moves everything.
- **A device redeems the last seven days by itself.** An hour or more after it was linked, a device
  sends `27` for its own unfilled placeholders from the last seven days that travel unasked (under
  500 MiB), newest first, at most two outstanding at a time, each retried and given up as a mirrored
  photo is. This is the heal for media: a share whose chunks were lost fills without a tap.
- **The phone answers one at a time and never twice.** A `27` joins the phone's back-fill queue at its
  front, behind only the attachment in flight; a `27` for an attachment already queued or in flight to
  that device is dropped, since it is the same send.
- **Above the in-memory ceiling the chunks are cut from disk** (§5.6a's streamed tiling, chunk sizes
  by the device's card), and a device's streamed assembler fills the placeholder the set names exactly
  as the in-memory one does: a set from the profile's own phone is never filed as a new message.

#### What the phone must check before answering

**A request naming a message is a request to disclose content.** So, in this order:

1. The sender is on **this phone's own linked-device list**. Not "claims to be" — the list the
   phone holds, which nobody else can add to.
2. The phone actually holds that message in that thread. A device may only redeem what it was told
   about, but the phone does not rely on the device to enforce that.
3. The attachment exists on disk. If not, the answer is `28`.

#### One request at a time per message, and no retry by the protocol

The phone queues the send as background work and does not acknowledge the request. A second request
for a message already queued is ignored rather than queued twice — a person tapping a placeholder
that has not visibly changed is the expected case, not an error, and duplicating a 2 GB send because
somebody tapped twice is the failure this avoids.

**There is no timeout on the wire.** A device that hears nothing stays in *fetching* and a person
may tap again; that is a UI decision and not a protocol one, because the phone may legitimately be
offline for a week.

### 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 **and
  the thread name**, computed as §5.2.2 defines. It lets a recipient detect that its view of the
  thread 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.

  > **Amended `J416`.** This originally covered the member identities alone. The name is included
  > because a rename is exactly the kind of divergence this field exists to surface, and because
  > without it a name changed while a member was offline would linger on that member's phone with
  > nothing to correct it. The amendment is recorded rather than made silently, since the digest is a
  > wire value and a reader of the older text would compute different bytes.

**`MAX_GROUP_MEMBERS`** **[policy]**, default **24**, MUST be enforced rather than advised. Fan-out
costs N× bandwidth and N× relay storage, and courier retention is where that bites. 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.

> **24 since 16 September 2026 (`DECISIONS.md` J902), raised from 6.** The original number was chosen
> from the cost model alone — *"six rather than sixteen nearly halves the worst case exactly where it
> hurts"* — which is true and was the wrong question. **It was never checked against a group anybody
> actually has.** The first real one named for this product, a medical practice of about twenty,
> does not fit in six, and a ceiling that cannot hold the first named use is not a conservative
> default but a feature that does not work.
>
> The raise is bounded and was measured rather than assumed. A full roster is 24 × `IDENTITY_LEN +
> PREKEY_BUNDLE_LEN` = 3,864 bytes plus CBOR framing, against `MAX_ENVELOPE_BYTES` of 65,536 — the
> envelope was never the binding constraint. The roster does not ride every message (§5.2.1: it
> accompanies the first message and membership changes), so the recurring cost is the fan-out alone,
> 23 sends per message rather than 5.
>
> **What genuinely gets worse is distinguishability, and it is recorded rather than dismissed.** A
> mailbox that sees 23 deposits in one instant has better evidence of a group than one that sees 5.
> Fan-out was always observable in that way; 24 makes the signal louder, and no change here hides it.

**Status: specified and implemented (`J416`).** This section previously read *"fields reserved,
implementation deferred past v1"* and required a v1 implementation to **reject** `groupId` on receipt.
That rule is superseded by §5.2.1–§5.2.6 below. **A build that does not implement this section still
refuses `payloadType` 13–17 outright**, which is the same outcome by a different route and is what
`J72a` requires of any unknown type.

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

#### 5.2.1 The roster

A group message carries, when §5.2.4 requires it, the full **roster**: the participants, each as an
identity and a signed prekey bundle.

| Field | Type | Notes |
| `identity` | bstr 32 | Ed25519 identity public key. |
| `prekeyBundle` | bstr 129 | The §6.6 bundle, **signed by `identity`**. |

A roster MUST be refused **whole** — never partially applied — when any of the following holds:

1. any `prekeyBundle` does not verify under the `identity` beside it;
2. the same `identity` appears twice;
3. the count exceeds `MAX_GROUP_MEMBERS`.

**The first rule is the load-bearing one.** Without it a participant could hand out a prekey for an
identity they do not hold, which is how somebody would insert themselves as a recipient of everyone's
future messages. It is the rule the device list (§5.8) already applies, for the same reason, and the
wording there — *"a list this phone half-applied would be worse than one it ignored"* — governs here
unchanged.

**The bundle is what makes a group reachable at all.** A participant must be able to address people
they have never paired with, so the bundle travels with the roster and §6.6's agreement establishes
the pairwise secret with no ceremony. The assurance that results is **below** a pairing and MUST be
shown as such: whoever added a participant vouched for them, and a wrong *name* against a correct key
is exactly the substitution §6.5.8's comparison exists to catch.

#### 5.2.2 The digest

    groupDigest = SHA-256(
        0x01 ||
        uint16be(count) ||
        Σ ( identity || prekeyBundle )   sorted by identity, ascending, lexicographic ||
        uint8(nameLength) || nameBytes
    ) [0 .. 8]

**`nameBytes` is the name exactly as it travels on the wire, never a display form.** A digest computed
over a sanitised name would differ between two platforms that sanitise differently — which is not
hypothetical: `J335` found iOS and Android disagreeing about exactly that, silently, for as long as
the field had existed. The digest is over transmitted bytes for the same reason every other value in
this document is.

`nameLength` is 0 and `nameBytes` empty for an unnamed thread.

#### 5.2.3 Payload types

| Type | Meaning | Who may send |
| 13 | Group message | any participant |
| 14 | Left the thread | anyone, **for themselves only** |
| 15 | Thread ended | the `creator` only |
| 16 | Removed a participant | any participant |
| 17 | Roster request | any participant |

> **`J929`, 16 September 2026: types 14, 15, 16 and 17 were decoded by both apps and SENT by
> neither.** §5.2.4's third case for a travelling roster — *an answer to a request* — therefore had
> no implementation anywhere, so a receiver that noticed its membership had drifted (the digest is
> carried for exactly that purpose) could neither repair it nor be repaired.
>
> Type 17 now travels. A receiver whose own roster digest disagrees with an arriving one sends a
> request **to that sender**, not to everybody: they are the one whose list is known to differ,
> asking all N would be N envelopes to learn one thing, and this table already permits any
> participant to answer. **Asked once per drift** — on the transition into disagreement rather than
> on the state — which needs no timer, because the flag is cleared by the first message that agrees.
>
> The answer generates no traffic of its own: the asker is recorded as owed a roster, and §5.2.4's
> "answer to a request" is that roster riding the next message sent to them.

**Type 14 carries no identity field.** A departure names its sender and nobody else, and the cleanest
enforcement of that is to leave nowhere to write another name. A check can be forgotten; an absent
field cannot.

Type 13's `body` is the message text. Types 14, 15 and 17 always carry the `groupId` (payload key
8), and MAY carry the thread's descriptive fields -- digest, creator, roster, name (keys 9, 11, 12,
13) -- exactly as type 13 may. **Those fields are never authority for the control action.** A
receiver decides "ended" against the creator it already holds, not the one the payload names (both
apps require the two to agree), and a roster riding a control message is read as §5.2.4 reads any
roster. Only type 16 carries the removed `identity` (key 14); the core refuses key 14 on every
other type, and refuses any group key on a payload with no `groupId`.

*This paragraph said 14, 15 and 17 carry "only" the `groupId` until 27 September 2026, while the key
11 row above said the creator is "present on group payloads" and both apps read a type 15's
creator. The security sweep found the contradiction; refusing the fields would have dropped a
spec-following "ended" and bought nothing, since a participant can put the same fields on a type
13. So the text now says what the implementations do.*

Types 56, 57 and 58 (§5.38) are group types as well. They carry the same fields a type 13 may, and
key 14 is still refused on them. They are not membership controls, so they are not in the table above.

Type 13 MUST NOT be compressed (`compressed` = 0) when it carries a roster: the bundles are
high-entropy signature material that never shrinks, and §5.4's CRIME note applies to any body a
participant partly controls.

#### 5.2.4 When the roster travels

The roster and name are sent **in full** on:

1. the first message of a thread;
2. any message whose roster or name differs from the last one this sender sent;
3. any message answering a type-17 request.

**Every other message carries the digest alone.** A roster is 161 bytes per participant, so repeating
it would put a one-line message in a padding bucket sixteen times larger than it needs (§5.3) — on
every one of N copies. The digest is 8 bytes and is what makes the omission safe: a recipient holding
a different roster **detects** it rather than diverging silently, and asks.

A recipient whose held digest does not match MUST NOT guess. It SHOULD send type 17, at most once per
thread per hour, and MUST surface the divergence rather than rendering a roster it knows is stale.

#### 5.2.4a Membership updates (`J1718`)

A **membership update** is a type 13 whose text is empty and which carries the full roster and the
name (keys 9-13). It says "this is the thread now" without being a message: the receiver applies it
under §5.2.5 exactly as it would the same fields on a message -- only the creator's establishes an
unknown thread, a roster only enlarges -- and then **files nothing, shows nothing and notifies
nothing**. A participant sends one to every other participant when it admits somebody (§5.2.7's
type 45), so the newcomer and everybody else learn the list at once rather than at the next message.

**Readers first, per recipient (`J1741`).** A build that predates this section files an empty type 13
as a blank message. The readers shipped in iOS 2.886 and Android 2.887 without advertising themselves,
so the writer (`sendsMembershipUpdates`, on) sends an update **only to a contact whose card carries
`gmu`** (`CardCapability.membershipUpdates`), which every build from `J1741` on advertises. A card
without it is sent nothing new and learns the list at the next message that carries the roster, as
before this section. The update is deposited quietly (no banner, `J1142`): it files nothing. A
newcomer that writes before it has collected the update reaches only its inviter with that post.

#### 5.2.5 Receiver rules

A receiver MUST:

1. **Refuse a type 13 whose `senderIdentity` is not in the roster it holds for that `groupId`.**
   Without this, anyone who ever received one copy — a participant who left, or one who was removed —
   keeps a pairwise channel and the `groupId`, and can post into the thread indefinitely.
2. **Establish an unknown `groupId` only from a message whose `senderIdentity` equals its `creator`.**
   The first message otherwise *defines* the thread, and ordinary reordering would let a fabricated
   one win.
3. **Honour type 14 for its sender only** — which §5.2.3 makes structural.
4. **Honour type 15 only from the `creator`**, and treat it as a courtesy: participants who are
   offline, or who have kept what was said, are unaffected. It MUST NOT be presented as deletion.
5. **Adopt type 16 from any participant**, attributed to its sender.
6. **Adopt an enlarged roster from any participant**, attributed. Membership is socially governed, not
   cryptographically: every change names who made it, permanently, and nothing prevents a change being
   undone by somebody else.
   **Enlarged only.** A roster riding a type 13 is merged into the one held: every identity already
   held stays, and those it adds are appended. It MUST NOT remove anybody -- departure and removal are
   types 14 and 16, and a shorter list on a message is a sender with a stale or partial view. A thread
   holding no roster takes the first one it adopts whole. (`J1697`: a member holding a partial list
   replaced the membership on every other phone, the creator's included.)
7. **Deliver to each participant's known devices** (§5.8). A roster entry is an *identity*, and an
   identity has devices; a bundle reaches one of them. Where no device list is known, only the
   snapshotted bundle is reachable, and that limit MUST be visible rather than reading as silence.

#### 5.2.6 What this design does not defend against, stated rather than implied

- **A group discloses its membership to its members.** Adding somebody hands them every participant's
  identity and bundle, and hands every participant theirs. Removing them afterwards does not take that
  back. No message history moves — there is no shared key and nothing retroactive — so what an
  addition exposes is the membership and not the conversation.
- **Fan-out is inferable from timing.** N deposits from one client inside a short window is itself a
  signal, and jitter spreads it without hiding the count. What the server never gets is a *record*:
  there is no group object, no allow-list, and no relationship that outlives an epoch.
- **Nothing here is enforced by cryptography.** Anyone may add, so anyone may add back. The constraint
  is attribution and the social cost of overriding somebody in front of everybody, which is the
  enforcement that holds in real groups. This is a deliberate choice (`J416`) and not an omission.

#### 5.2.7 Invitations: nobody is put into a group without saying yes (`J1689`)

The owner, 28 September 2026: *"the invited person should have to approve joining -- really same for
informal group since being in a group has security consequences a user should accept."* §5.2.6's first
bullet is those consequences: an addition hands the newcomer every participant's identity, and hands
every participant theirs. Asking **after** the roster has travelled would ask permission for something
already done, so the question comes first.

| Type | Meaning | Sent by | Sent to |
| 44 | Group invitation | a participant | the invitee only, pairwise |
| 45 | Invitation accepted | the invitee | the inviter only, pairwise |
| 46 | Invitation declined | the invitee | the inviter only, pairwise |

**Fields.** Each carries the `groupId` (key 8). Type 44 carries its `messageId` (key 10), which is the
**invitation's identity**, and the thread's name (key 13) when it has one. Types 45 and 46 MUST carry
`replyTo` (key 15) equal to that `messageId`, and -- because §5.1a lets `replyTo` travel only beside
one -- a fresh `messageId` of their own. **None of the three carries a digest, a creator, a roster
or a removed identity** (keys 9, 11, 12, 14), and the core refuses them if present: an invitation
discloses the thread's name and who is asking, and nothing about the other members, because consent
has not been given in either direction. The inviter is the envelope's authenticated sender.

**Who is sent one.** A contact whose card advertises `ginv` (`CardCapability.groupInvitations`) is sent
type 44. A contact whose card does not is added as before this section -- directly, in the roster --
because a build without the reader refuses types 44-46 (`J72a`) and an invitation to it would vanish.
There is no global switch: the capability is per contact, so each invitation goes only where it can be
read, and the direct add covers everybody else. Readers ship before any phone advertises `ginv`.

**The inviter.** It records each outstanding invitation as `(groupId, invitee, invitationId)` and MUST
NOT put the invitee into a roster it sends until a type 45 arrives **from that invitee, for that
`groupId`, replying to that `invitationId`**. An answer that matches no outstanding invitation is
ignored -- otherwise anybody holding a pairwise channel and a `groupId` (a former member does) could add
themselves by sending type 45 -- and a matched answer consumes the invitation, so a replayed 45 adds
nobody. After 45 the invitee is added exactly as `addParticipant` adds anybody, and the roster reaches
everyone by §5.2.4. Type 46, or no answer, leaves everything as it was. The inviter's phone shows the
invitee as *invited* and not as a member, and may withdraw the invitation, which forgets it.

**The invitee.** A type 44 is shown as an invitation naming the group and the inviter, with Accept,
Decline and an explanation of what joining discloses. Accept sends type 45; Decline sends type 46; both
forget the invitation. **Accepting grants nothing on the receiving side**: §5.2.5 rule 2 is unchanged,
so the thread becomes authoritative on this phone when its creator writes, exactly as for anybody a
non-creator adds (an earlier draft of this section let the accepted inviter's roster stand in for the
creator's, and review found that it handed creator authority to anybody who could get a yes, including
a former member). **But the thread exists at once (`J1741`)**: Accept makes a local thread for the
`groupId` with the invitation's name, holding this phone and the inviter, and **no creator** -- an
invitation does not say who created the group, and recording the inviter would make rule 1 drop the
real creator's posts. The invitee may write in it immediately; its posts reach the inviter, who
admitted it, and everybody else once the inviter's membership update (§5.2.4a) has named them to it and
it to them. Until a roster from the creator it claims arrives, the thread is unknown under rule 2
exactly as a thread with no local copy was: a post is readable and establishes nothing. A type 44 for a `groupId` this phone already holds as a member is ignored. At most
`MAX_PENDING_GROUP_INVITATIONS` **[policy]**, default 20, are held; beyond that the oldest is dropped.

**A direct add from a build without `ginv`** -- a roster naming this phone, for a `groupId` it does not
hold, from a sender whose card does not advertise `ginv` (or whose card this phone has not seen) -- is
held as an invitation from its sender: the group fields it carried (roster, creator, name) are kept
aside and nothing is adopted, filed, merged into a one-to-one chat or notified. Messages that arrive
before Accept are not kept (`J1689`, 28 Sep 2026: keeping a hidden thread is what lets one leak into a
list, a badge or a notification). Accept adopts what was held under §5.2.5 as if it had just arrived;
no type 45 is sent, because that sender cannot read one. Decline forgets it and refuses that
`groupId` on this phone from then on (at most 100 remembered **[policy]**); nothing is sent, because
no build without `ginv` reads an answer and no build implements type 14. The disclosure has already
happened in this case, and the screen says so rather than implying otherwise.

#### 5.2.8 Introductions: members who have never linked (`J1688p`)

§5.2's mailboxes are pairwise: a deposit is filed under an address derived from two parties' shared
keys, and a phone collects only from the addresses of contacts it holds keys for. A roster hands every
member each other member's identity and signed prekey bundle (§5.2.1), and a bundle is enough to
**derive** a key -- but the other side cannot derive the same key without the ephemeral half, and there
is no address at which to hand it over. So two members who never linked each other each hold the
other's bundle and can reach nothing: the owner, 28 September 2026, *"group members still don't see who
is in the group and their messages don't go anywhere."* An introduction carries the ephemeral half
through a member who holds keys for both.

**Bundles travel on the card.** A card (§5.7) carries its sender's own signed prekey bundle as `pkb`.
Without it a creator holds bundles only for contacts it reached at a published value (`J852`), leaves
everybody else out of the roster it sends, and those people cannot be introduced to anyone.

**[policy] A participant with no bundle is asked for one, and is never dropped for lacking it**
(`J2471`). A device that lists a participant it holds keys for and no verified bundle leaves them
out of the roster it builds, sends that contact its card marked `want` (at most once in six hours per
contact), and keeps them in its own list whatever rosters later arrive: a roster says nothing about
somebody this device lists and no roster held here has ever named. The answering card's `pkb` puts
them on the roster from the next message. No field changes; this is the receiver's rule.

| Type | Meaning | Sent by | Sent to |
| 47 | Group introduction | the initiating member, then the relaying member | the relay, then the target |

**Fields.** Type 47 carries the `groupId` (key 8) and none of keys 9-14, and is never compressed: an
introduction concerns one pair of members and says nothing else about the group. The core refuses
anything else. A build that predates this section refuses type 47 at decode (`J72a`), so nothing is
filed or shown there.

**Capability.** A build that implements this section advertises `gint` on its card. An initiator sends
only through a relay whose card advertises it, and a relay forwards only to a target whose card does.

**Body**, 96 bytes and then the card, bounds checked by the core before anything is read:

```
to (32) ‖ from (32) ‖ ephemeral (32) ‖ sealedCard (1 .. MAX_INTRODUCTION_CARD = 4096 bytes)
```

`to` and `from` are identities; `ephemeral` is the v2 sender ephemeral of §6.7.3 (`prekey.rs`
`as_sender_v2`); `sealedCard` is an ordinary sealed envelope (§4) of type 7 under the keys that
agreement derives. `from` MUST sort strictly below `to`, bytewise. The `groupId` is not bound into the
agreement; it does not need to be, because what an introduction creates -- a member contact -- belongs
to the pair and not to one thread, and a body replayed under another `groupId` reaches the same pair.

**Who initiates.** For any two members B and C of a thread where neither holds keys for the other,
**the one whose identity sorts lower initiates** and the other waits: one rule, so two phones never mint
two sessions for one pair. B initiates when its participants include C, its roster holds a bundle for
C, and it holds no keyed contact for C. It runs `as_sender_v2` with its own prekey secret against that
bundle, derives the pairwise keys exactly as a v4 invitation does, and **keeps them at once as a
pending member contact for C** -- so it collects from that mailbox from this moment, and a card C sends
back cannot sit where nobody looks. It seals a member card to those keys and sends type 47 through one
relay at a time, the roster's sender first.

**The pending introduction is kept, not recomputed.** B stores the body it sent and re-sends it
byte-identical on each retry. It mints a new one only when C's bundle in its roster, or its own prekey,
has changed -- and then replaces the pending keys with the new ones. Retries go once a day for 7 days,
then once a week, **for as long as a thread names both** **[policy]**; each retry may choose the next
relay. So a target that was offline, on an older build, or holding a roster that did not yet name B, is
reached the next time after it has caught up. B MUST NOT seal group messages to C until a card from C
opens under the pending keys; until then, copies addressed to C stay queued.

**The relay** (a type 47 whose `to` is not this phone). Forward it, byte-identical, sealed to `to`,
announcing nothing, only when **all** hold: the envelope's authenticated sender is `from`; this phone
holds the thread and its participants include both `from` and `to`; it holds keys for `to` and `to`'s
card advertises `gint`; and it has not forwarded that `(groupId, from, to)` in the last 24 hours
**[policy]**. It remembers at most 1024 such triples **[policy]**, forgetting the oldest. Otherwise
drop it. A relay reads nothing and adds nothing -- it cannot open the card, and forging one would need
`from`'s prekey secret (§6.7.3: v2 binds the sender's own prekey).

**The target** (a type 47 whose `to` is this phone). Drop it unless **all** hold: `to` is this phone;
`from` sorts below `to`; this phone holds the thread and the envelope's authenticated sender is a
participant of it; `from` is a participant of it and this phone's roster holds a bundle for `from`;
`from` is not this phone's own identity, the identity on any other contact's card, or one of any
contact's device sessions (`J437`); and this phone holds no keyed contact for `from` **other than a
member contact**. Then the core verifies the bundle against `from`, checks `to` is this phone, and runs
`as_recipient_v2` -- the selection is the core's, as for an invitation (§6.7.3 rule 3), so no caller can
pair one identity with another's prekey. **The introduction is proven only when `sealedCard` opens**
under the derived keys (rule 4); if it does not, nothing is kept or changed.

**Newer only (`J1696`, 28 September 2026).** An introduction that opens is not by that fact current: a
relay or a former member can re-send an older one, still valid under this phone's prekey, and taking it
would move the member contact back to keys the initiator has thrown away. So the target orders them by
the sealed card's `sentAt` (§5.1 key 3), which the AEAD authenticates and nobody but the initiator can
set. It keeps, beside a member contact's keys, the `sentAt` of the introduction that set them, and
takes an introduction's keys only when its `sentAt` is **greater**; an equal one with the same keys is
the initiator's byte-identical retry and changes nothing, and anything else is dropped. The initiator
gives each new mint a `sentAt` greater than its previous mint for that pair, whatever its clock says.

**One bundle per member (`J1696`).** When several threads name the same member with different
bundles, the initiator mints against one: the bundle on that member's own card when it holds one,
otherwise the one in the most recently adopted roster. A re-mint keeps the retry schedule where it was;
it does not start it again.

Proven: with no member contact for `from`, one is made; with one whose keys equal the derived keys,
this is a retry and nothing changes; with one whose keys differ, the proven keys replace them (B minted
anew). **In every case this phone sends its own member card back**, as type 7 under those keys -- so a
card lost on the way back is replaced by the next retry, and B learns the session works. A replayed old
body cannot roll the keys back: it opens only against the bundles it was minted from, and B mints anew
only when one of those changed.

**A member card** is the card of §5.7 carrying `name`, `caps`, `routes`, `notify` and `pkb`, and
nothing else. A receiver keeps only those fields from a member contact's card, whatever else it holds.

**A member contact** is held like any contact for delivery, with these differences: it is not listed
among contacts, offered in any picker, or sent this phone's full card, updates or device list; it is
shown in the thread's member list under the name its member card carried; it goes when the last thread
naming it does; and pairing with the same identity later turns it into an ordinary contact rather than
making a second card (`J309`). Member contacts are bounded by the rosters that name them: at most one
per participant, and a roster is bounded by §5.2.1.

**What this discloses, beyond §5.2.6.** To **every contact**: this phone's signed prekey bundle, on the
card -- which lets a contact who adds you to a group introduce you to its members, and does nothing else.
To **every member of a thread you are in**: your name as you give it, your card's capabilities and
routes, and your notify id, which lets them wake this phone exactly as a contact can -- and which, like
any contact's, is not recalled when the thread ends. The relay learns that `from` is reaching `to` in
that thread, which it knew. In an informal group any participant may add anybody (§5.2.5 rule 6) and
§5.2.7 asks the newcomer, not the members, so **joining an informal group means being reachable by
whoever its participants add**; the thread records who added whom. A group whose membership one
person controls is a formal group (§5.24). The card's name is the member's own claim; the member list
shows it as theirs.

### 5.3 Padding — mandatory

After CBOR encoding, the plaintext is zero-padded up to the smallest bucket that fits it, **never
below `MIN_PADDING_BUCKET`**:

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

**The floor exists because the bucket was a type label** (`DECISIONS.md` J243). The rule below says
unpadded length distinguishes "ok" from a paragraph; it stopped short of the other half. With 256
reachable, a delivery receipt and a read receipt land in 256 while almost every real sentence lands in
1024 — and the two types that always took the small bucket are the two emitted automatically, in
patterns that say when a message arrived and when it was read. The floor costs at most 768 bytes on
the smallest payload and makes every receipt, location and ordinary message one size.

256 stays in `PADDING_BUCKETS` rather than being deleted: a decryptor must accept a payload padded to
any bucket, because the floor is a **sender-side** rule and an older sender is still speaking this
version of the protocol. That is also why the floor needs no version bump in either direction — the
check below is "every remaining byte is zero", never "this is the smallest bucket that fits".

**The two top buckets (`J2586b`, 9 October 2026).** A sealed payload is its bucket plus about 249 bytes
(measured: 65,273 for 65,024), and the ratchet (§4.5a) may add a post-quantum offer (+1,184) and a
ciphertext (+1,088) to one message. The envelope's `payloadLength` is a `u16` inside the AAD
(`aad[39..41]`), so the sealed payload must be at most 65,535. A payload padded to 65,024 with an
offer attached is 66,457 and cannot be sealed. 62,976 + 249 + 1,184 + 1,088 = 65,497 fits, so a
sender whose payload would otherwise land in 65,024 and that has an extension to attach uses
payloads of at most 62,976. 65,024 stays for a payload above 62,976 with no extension attached.
Adding the bucket changes no wire format: a decryptor accepts any bucket (the check below is on
trailing zeros only), so an older build opens a 62,976 payload.

**Attachment chunk sizes (`J2586b`).** A sender cuts large media (over 2 MiB, to a contact whose card
says `bigc`) into `LARGE_CHUNK_BYTES` = 61,952 bytes = 62,976 less the same 1,024-byte margin for the
chunk's and the payload's CBOR maps that the former 64,000 left against 65,024, so a full chunk pads
to 62,976. `MAX_CHUNK_BYTES` = 64,000 remains the **receive and validation** bound: a decoder, the
tiling check and the reassembler accept any chunk up to 64,000, so a set cut by an older build is
still read. Nothing about the chunk size is on the wire; the receiver reads each chunk's own length.
At 61,952 a 5 GiB object is 86,659 chunks, under `MAX_ATTACHMENT_CHUNKS` (131,072).

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.

**The same rule applies to an attachment's last chunk** (`J605`). §5.6 zero-fills it to full length so
every chunk is byte-identical on the wire; a receiver **MUST verify those fill bytes are `0x00`**
before `totalBytes` truncates them away. Padding that is written but never checked is the covert
channel this section exists to close, and it does not stop being one because it sits a layer lower.

### 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 -- except key 5, which is the sender's own timer alone (`J1528`, `J2841`, below).
- **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.

**Key 5 on a message is that message's own timer (`J1528`).** The owner, 25 September 2026: *"Senders
set ttl for messages."* A sender that has a disappearing timer set for the conversation writes it,
in seconds, into the policy map of each message it seals; a receiver deletes that message the given
number of seconds after it arrives. It is the sender's choice for the sender's message, so a
receiver does not shorten or lengthen it by its own setting -- its own setting governs the messages
it sends. This is what makes a timer work in a group, where there is no single peer to reconcile
with: each member's copy carries the timer it was sent with.

- **Absent means no timer on this message**, and is what every build before `J1528` sends. A
  receiver then falls back to the conversation-level timer those builds exchanged (`J88`), so an
  old sender's messages behave exactly as before.
- **An untimed message seals an empty policy map**, byte-identical to earlier builds; the key is
  written only when a timer is set.
- Readers first is already satisfied: every build since the core existed parses the policy map
  (`decode_policy_map`, bounded and duplicate-refusing) and ignores keys it does not act on, so an
  older receiver simply keeps the message -- which is what it did before.

**`spaceFull` in a POLICY_UPDATE body (`J2553`; app level, optional).** Separate from the policy map
above: the POLICY_UPDATE payload's app-owned JSON body (`{"v":1, ...}`, §5.5 keys `viewOnce`,
`enhancedPrivacy`, `disappearAfter`) may carry ONE more key, `"spaceFull": "<set id>"`, where the value is the
attachment set id the slices carry, written as exactly 32 lowercase hex characters (the sixteen bytes of §5.1a).
The receiver of a file whose phone has no room sends it, at most once per set per hour, to the file's sender; the
sender shows "their phone is out of space" on that transfer for an hour or until any receipt arrives. Any other
value (wrong length, upper case, non-hex, not a string) is ignored. It is acted on only for a set that is one of the
reader's OWN outbound messages in the thread the envelope was filed to (so it is also the only field a group thread
accepts from a policy update); it changes nothing but that display. Builds that predate it ignore unknown keys.
No core or wire-format change.

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.

---

### 5.18 File attachments (`mediaType 5`)

An arbitrary file travels as an ordinary attachment with `mediaType = 5`. The core does not
interpret the bytes and neither app renders them: a receiver shows a name, a size and an icon, and
hands the bytes to the operating system only through an explicit tap.

**The display name rides inside the attachment, ahead of the file:**

```
u16 BE   nameLength      1 ..= 255
bytes    name            nameLength bytes of UTF-8
bytes    file            the rest
```

It cannot ride in the message body, because a body is sealed and delivered as its own text
envelope (§5.1a) and would land at the receiver as a separate bubble. Inside the attachment it is
sealed with the bytes it names, so it is the sender's own claim.

**A receiver treats the name as hostile.** It is refused -- and with it the whole attachment,
the way a malformed chunk is -- when `nameLength` is 0 or over 255, runs past the bytes present,
is not valid UTF-8, or names something a sender following this section could not have produced:
a path (`/` or `\`), a control character, a `;`, a leading `.`, surrounding whitespace, or more
than 120 characters. Both apps apply the same sanitiser to what they send and require equality on
what they receive (`FileMessage`, twin implementations, twin tests with a shared byte fixture).

**One sanitiser, in the core (`J2998b`).** The rule above is `file_name::sanitize` in `mesh-core-rs`, called by
both apps (C entry `meshcore_file_name_sanitize`, JNI `fileNameSanitize`), so it cannot drift: the limits are 120
Unicode scalar values and 255 UTF-8 bytes; the last path component is kept; control characters, line and
paragraph separators, `;`, bidirectional controls (U+061C, U+200E/F, U+202A-202E, U+2066-2069) and zero-width
characters (U+180E, U+200B-200D, U+2060, U+FEFF) are removed, and so are surrounding Unicode white space and any
leading dot; a long name keeps a short extension. A name is accepted only when it comes back unchanged, which
refuses a bidi or zero-width name instead of showing it. `vectors/58-file-name.json` holds the rule, the header
read and the chunk read to the core, Swift and Kotlin.

**The name before the transfer completes.** The header always sits at the front of chunk 0, so a receiver may
read the name from chunk 0 as soon as it arrives (`meshcore_file_name_from_chunk` / `fileNameFromChunk`: chunk 0
of a File only, the header passing every bound above) and show it on the "Receiving" row. It changes nothing on
the wire; a chunk that is not chunk 0, or whose name is refused, leaves the row generic.

**Capability.** A build that knows `mediaType 5` advertises `fil` in its card's `caps` (§5.10),
alongside `vid` and `bigc`. A sender does not put a file on the wire to a peer whose card lacks it;
an older receiver would refuse the chunks as `Malformed`, which is correct, and would show nothing.
The sender holds the message and sends a plain-text note instead, as it does for video.

**Size.** The ceiling is the attachment ceiling for that peer (§5.6): `MAX_ATTACHMENT_BYTES` (5 GiB, 131,072 chunks) to a
peer whose card carries `g5`, 3 GiB (65,536 chunks) to a `bigc` peer without it, and the standard-chunk ceiling otherwise.

**`g5` (`J2013`).** Capability tag: this build receives an attachment of up to 5 GiB. A build from before it
enforces 3 GiB and 65,536 chunks at the manifest and refuses anything larger without a trace, so a sender
puts more than 3 GiB on the wire only to a card that carries `g5` (the reader ships first). The core's
`max_attachment_bytes_for_peer(chunk_bytes, peer_takes_huge)` is the one rule. No wire field changes: byte
counts are CBOR integers and every counter on the way is 64-bit (`total_bytes`, offsets) except the chunk
index, a `u32`, which 131,072 does not approach. There is no file-specific cap.

**What this section does not do.** It does not restrict file types on the wire. A sender may
refuse to *offer* certain extensions as a courtesy to its own user (`J636`); a receiver never
executes anything and never previews contents, so the wire needs no list.

*Added 17 September 2026 (`J981`).*

### 5.19 Replies (`replyTo`, key 15)

`J1482`. A message may answer an earlier one: key 15 carries that message's `messageId` (§5.1a),
exactly as a reaction's `to` does (§5.12) -- **whichever side sent it**, so answering one's own
message and the other person's are the same shape.

**It names the message and carries nothing of it.** No quoted text, no caption, no thumbnail and no
sender name travel with a reply. The receiver draws the quote from **its own copy** of the named
message, looked up by `(messageId, conversation)`:

- **Held**: shown as the receiver's copy says, including a view-once, which is shown by kind and
  never by content.
- **Not held** -- it expired, was deleted, was never received, or belongs to another conversation:
  the reply is shown with a marker saying the original is not available. A receiver MUST NOT ask
  for it, and MUST NOT resolve a `replyTo` against any conversation but the one the reply arrived in.

Three properties follow from carrying only the identifier, and they are the reason for the design:

1. **A quote never outlives what it quotes.** A disappearing message that expires takes its quote
   with it on both phones, because neither phone holds a copy of it anywhere else.
2. **§5.4's forward rule is satisfied by construction.** No received content shares the reply's
   compression context, because no received content is in the payload at all.
3. **A reply cannot misquote.** A sender cannot put words in the quoted message that it did not
   carry, since the receiver never reads a quotation from the sender.

**Rules.** `replyTo` is exactly 16 bytes; any other length is rejected rather than truncated.
It appears only on a payload that also carries `messageId` (key 10), because only a message can be
answered by a message; a payload with key 15 and no key 10 is malformed, and an encoder refuses to
build one. A receiver that does not know key 15 skips it (§1.2) and shows the reply without its
quote, which loses nothing that was promised.

### 5.20 Call request (`payloadType 30`)

`J1486`. A first contact may ask to CALL rather than to write. The request is the first message of
an invitation (§6.5, `J276`), sealed where a text would be, with `payloadType` 30 and an empty
body, and it is decided exactly as a message request is: the receiver accepts or blocks.

**It never rings.** A receiver MUST NOT start any call interface for a call request, and MUST NOT
hand one to the call signalling of §5.15. It is a line in the requests list saying the person
would like to call. The call comes after acceptance, over the pairing acceptance creates, and is
then an ordinary call under §5.15.

It reaches nobody who could not already be written to: the same published values, the same
invitation, the same block list. It adds a wish to the request, not a route.

A receiver that does not know type 30 cannot open the first message and files the request without
a preview (§6.5's rule that a request is kept even when its message will not open), which loses
the wish and nothing else.

### 5.21 Delete for everyone (`payloadType 31`)

`J1525`. The owner, 25 September 2026: *"No edit for sent messages though you can delete if not yet read."*

**The author withdraws a message the other phone has not read.** The body is exactly the 16-byte
`messageId` (§5.1a, key 10) of the author's own message -- no text, no reason. It is pairwise, like a
reaction (§5.12): in a group, each member is sent one.

A receiver acts on it only when **all** of these hold, and otherwise ignores it without answering:

- the body is exactly 16 bytes;
- a message with that `messageId` exists on this phone **from the same sender** -- the authenticated
  sender of the delete (§5.1 key 1, opened by the AEAD) is the message's own sender. Nobody can
  withdraw somebody else's message, in a one-to-one or in a group;
- that message is **unread on this phone**. A message already read stays: the rule is "if not yet
  read", and a receiver applies it itself, so it holds when read receipts are off and the author
  could not have known.

Acting on it removes the message, its attachment and any notification it raised. Nothing is sent
back.

**Readers first.** Every build before `J1525` refuses payload type 31 as unknown, so a phone must
not offer the choice until the people it would reach are on a build that reads it.

### 5.22 Direct link signal (`payloadType 32`)

`J1546`. The owner, 26 September 2026: *"All messages and calls should prefer ip6 if available since it
bypasses the server and relay"*, and *"prefer 6 over I"*.

**Setting up a direct link between two contacts' phones**, over which sealed envelopes then travel
phone to phone: a WebRTC data channel, negotiated the way a call is (§5.14) and carrying no media.
The body is opaque UTF-8 JSON that the apps own, for the reason the call types are: SDP changes with
the media stack, and the wire format must not. The core carries it and never parses it.

```
{"v":1, "k":"offer"|"answer"|"candidate"|"close", "id":"<link id>", ...}
```

- `offer` and `answer` carry `sdp`; `candidate` carries `c`, one or more ICE candidates; `close`
  may carry `r`, a reason. `id` names one link, chosen by the offerer.
- **No relay.** A link is negotiated with no TURN server, so a candidate pair is either direct or
  nothing. It is **used only while its selected candidate pair is IPv6 on both ends**; any other
  pair closes the link and the envelope goes by the mailbox. So the route mark `6` (§7) means
  exactly one thing.
- **What travels on it is ordinary sealed envelopes** (§4), unchanged. The data channel's own DTLS
  is a second layer, not the protection: nothing on the link is readable without the pairwise keys,
  exactly as on every other rung.
- Pairwise only, never in a group, never a call type (`is_call` is false): the call coordinator
  rings, and this must never ring.

**Readers first; offered to every paired contact.** Every build before `J1546` (2.847) refuses
payload type 32 as unknown, which files and shows nothing. A phone reads the type from the build
that introduced it and advertises the card capability `dl6`. A sender offers a link to **every
paired contact, whatever its card says** (`J1601`): the card gate kept the link from every contact
whose card had not arrived since the capability shipped, and an older reader's refusal costs one
unreadable envelope. `dl6` remains informational, counted in the support report. A build that reads
the type with the link switched off drops it without answering.

### 5.23 Update (`payloadType 33`), retired (`J3791`)

`J1659` added Updates (a person's current status, for their contacts, for 24 hours); the owner removed
the feature on 1 November 2026 (`J3791`). **Nothing writes or shows this type any more.** The number
stays reserved, and `PayloadType::StatusUpdate` stays in the core, so that a packet from an older build
still parses as a known type and a build that has dropped the feature **ignores it unread**: it is not
filed, not notified, not rung, not kept, and never an error. The type is pairwise, never a call and never
a group type (`is_call` and `is_group` are both false), as it always was.

The body format (`status_update.rs`, `vectors/41-status-update.json`, `meshcore_status_update_*`) and the
card capabilities `upd` and `updm` are removed: a build no longer advertises either, so an older build
stops sending it updates once it has seen the new card. Do not reuse number 33.

### 5.23a Media sealed once (`J1674`)

A video or voice note (first for Updates, now for group media and a large Welcome) is too large to seal once per contact. Its media is sealed **once**, under
a key made for it alone, and uploaded **once** to a relay address made for it alone. Everything a
reader needs to fetch and open it travels inside each contact's pairwise envelope, so
the confidentiality and the authenticity come from the pairwise seal as they do for every Update.
`mesh-core-rs/src/media_once.rs` is the implementation.

**Sealing.** `key` is 32 random bytes and `address` is 16 random bytes, **both drawn by the core
inside the seal call**; no interface accepts a key to seal with, so a key seals exactly one
plaintext. The plaintext, 1 to 33,554,432 bytes, is cut into 65,536-byte chunks, the last possibly
shorter. With `n` chunks, chunk `i` (from 0) is

```
ChaCha20-Poly1305(key,
    nonce = 00 00 00 00 || uint64-BE(i),
    aad   = "channel/update-media/v1" || address || uint32-BE(i) || uint32-BE(n))
```

The **blob** is the sealed chunks concatenated, `plaintextLength + 16 n` bytes. `digest` is the
SHA-256 of the whole blob.

**The media object** (the Update header that first carried it is retired, §5.23) is

```
"media": {"kind":"v"|"a", "addr":"<32 lowercase hex>", "key":"<base64, 32 bytes>",
          "len":<plaintext bytes>, "sha":"<64 lowercase hex>", "dur":<seconds>}
```

`v` is a video (MPEG-4, at most 60 seconds) and `a` is a voice note (AAC in MPEG-4, at most 30
seconds).

**Opening.** A reader fetches the blob from the relay address and refuses it unless:
- its length is exactly `len + 16 ceil(len / 65536)`;
- its SHA-256 equals `sha`, **checked before anything is decrypted**;
- every chunk opens.

Every reader holds the key and could seal other bytes under it; the per-chunk tags cannot tell the
sender's blob from another reader's, and the digest, which arrived in the sender's own envelope, can.
The relay's write-once rule is a second fence, not the first.

**The relay.** `large?op=media-put&addr=&size=` returns a presigned upload for exactly `size` bytes
(at most the sealed length of 33,554,432), refused when the address already holds an object.
`large?op=media-get&addr=` returns a presigned download. The object is deleted 25 hours after it is
written. The address is the only capability: whoever has it can fetch
the blob, and cannot read it without the key. `media-put` is limited to 30 an hour from one
network.

**A sender uploads before it sends any header.** Until the object exists, only the sender's core
knows the address, so nobody can take the address first and lock the sender out of it. An upload
that fails is not retried at the same address: the Update is sealed again, under a new key and at
a new address, or not posted.

**Each media request carries a throwaway token.** The relay checks only a token's shape, and a
mailbox token would tell it which pair of people moved the Update, because mailbox tokens are
pairwise. A token drawn for one request says nothing about anybody.

**Readers first, gated by the card.** A sender sends the media object only to a contact whose card
advertises `updm`. A contact without it receives the same Update without `media`: a video's first
frame and any text, or nothing at all for a voice Update with no text.

### 5.24 Formal groups (`payloadType 34`-`37`, `J1669`)

A formal group has an admin. Everything the admin decides travels as a **charter**, which the admin
signs; `GROUPS-PLAN.md` §9 is the design and the reasoning. `mesh-core-rs/src/formal_group.rs` is the
implementation. All four types are pairwise. None carries the informal-group keys of §5.2, and none
is a call type.

**A formal group is named by its admin and its id together** (`GroupRef`), everywhere: in storage, in
every body below, and in every lookup. A charter for the same `gid` naming a different admin is a
different group.

**The charter** is canonical CBOR `{0: v = 1, 1: gid (16 bytes), 2: epoch (uint), 3: admin (32-byte
Ed25519 identity), 4: mode (0 read/write, 1 read-only, 2 moderated), 5: name (text, at most 48 bytes),
6: members (array of 32-byte identities, the admin included, none twice)}`. Every key is required and
no other is allowed. The ceiling is 256 members in every mode -- **for a §5.24 group, which no app starts
any more** (the owner, 28 September 2026: no 256 cap; every new formal group is a §5.25 group). The
signature is Ed25519 by the admin over `"channel/formal-group/v1" || charter`.

**What a receiver accepts:**

1. The first charter for a group only from its own admin, delivered pairwise. That admin is then
   pinned for the group.
2. A later charter only if it verifies under the pinned admin and carries a higher epoch, from any
   courier.
3. A post to the whole group -- a type-13 group message under the group's thread id, or a type-36
   re-post -- only from the admin, in every mode.
4. A submission (type 37) only at the admin, from a member, and never in read-only mode.

**Every formal group routes through its admin.** Members send to the admin alone (type 37); in
read/write the admin's phone re-posts at once, in moderated mode after approval, and in read-only
members do not post. The admin's own posts are ordinary group messages (§5.2, type 13) whose
`groupId` is the **thread id**: the first 16 bytes of SHA-256(`"channel/formal-group-thread/v1"` ||
admin || gid). It cannot be chosen, so it cannot collide with another admin's group or with an informal
group's random id. For a formal thread the §5.2 roster a type-13 message carries is ignored: membership
comes only from the charter.

Anything else is dropped whole.

| Type | Body (canonical CBOR map) |
|---|---|
| `34` FormalCharter | `{0: charter (bstr), 1: signature (64 bytes), 2: purpose (0 invitation, 1 change), 3?: roster}`. The optional roster is §5.2's and is reserved: no build sends one, because members reach only the admin. |
| `35` FormalMembership | `{0: admin, 1: gid, 2: kind}`. Kind is 0 join, 1 decline, 2 request, 3 leave (each sent to the admin), or 4 request declined (sent from the admin). |
| `36` FormalPost | `{0: admin, 1: gid, 2: epoch (the sender's), 3: text (at most 16,384 bytes), 4?: author (32 bytes), 5?: author name (at most 64 bytes)}`. The admin's re-post of a member's submission; the author and its name appear together or not at all. |
| `37` FormalSubmission | `{0: admin, 1: gid, 2: id (16 bytes), 3: text (at most 16,384 bytes)}`. |

Each body is strict: canonical order, the listed keys only, and no trailing bytes.

**Readers first, gated by the card.** Every build before `J1669` refuses types 34-37 as unknown, so
an admin invites, and a member posts to, only contacts whose card advertises the capability `fgr`.

### 5.24a Several admins: charter v2 (`J1680`)

**Status, 27 September 2026: SUPERSEDED BEFORE IT WAS BUILT.** The owner redefined formal groups the
same evening (`DECISIONS.md`, "Formal groups, redefined by the owner"): one copy of each message on
the relay, MLS through OpenMLS, members posting directly unless the group is moderated. Several admins
become MLS roles in that design. This section is kept because its analysis carries over: a
second signer cannot vouch for itself, so the main admin's authority must travel with every change.

The main admin stays fixed: they are part of the group's name (`GroupRef`). They may name up to **8**
other admins. Any admin may post, take and approve submissions, invite, accept requests and remove a
member who is not an admin. Only the main admin changes the name, the mode or the list of admins.

#### The problem a second signer creates

A v1 charter is trusted because the admin it names signed it, and a phone pins that admin the first
time. Let a co-admin sign, and a charter can no longer vouch for itself: anybody can write a charter
naming a real person as main admin and themselves as co-admin, sign it, and deliver it first. The
victim would pin the real group's `GroupRef` to a forgery, and with an epoch of 2^64 - 1 no real
charter could ever replace it.

#### The fix: two signatures, two scopes

What only the main admin decides travels as a **constitution**, which the main admin signs. What any
admin decides travels as the charter, which that admin signs, **and the charter carries the
constitution's signature**, so every charter proves the main admin's authority on its own, first
delivery or not.

```
constitution  canonical CBOR {0: v = 1, 1: gid, 2: admin (main), 3: mode, 4: name,
                              5: admins (array, at most 8, members other than the main admin, none twice),
                              6: cepoch (uint)}
              signed by the MAIN admin over "channel/formal-group-constitution/v1" || constitution

charter v2    canonical CBOR {0: v = 2, 1: gid, 2: epoch, 3: admin (main), 4: mode, 5: name,
                              6: members, 7: admins, 8: cepoch, 9: signer (32 bytes)}
              signed by SIGNER over "channel/formal-group/v2" || charter

type 34, v2   {0: charter v2, 1: charter signature, 2: purpose, 4: constitution signature}
```

A receiver rebuilds the constitution from the charter's own fields (`gid`, `admin`, `mode`, `name`,
`admins`, `cepoch`), so the two can never disagree, and accepts a v2 charter only when:

1. the constitution signature verifies under the charter's main admin;
2. `signer` is the main admin or one of the constitution's admins, and the charter signature
   verifies under `signer`;
3. every admin is a member (so no co-admin can remove an admin: the constitution fixes the list);
4. **order**: a charter replaces the held one only if `(cepoch, epoch)` is higher, compared as a pair.
   Equal pairs with different bytes (two admins acting at once): the lower charter signature wins,
   on every phone, with no conversation needed.

The first charter of a group no longer needs to come from the main admin in person: a co-admin's
invitation proves the main admin's authority through rule 1. `courier` stops mattering.

**A co-admin who misbehaves** (an epoch of 2^64 - 1, a flood of changes) is removed by the main admin
with a new constitution: its `cepoch` is higher, so it outranks every charter the co-admin can
sign. The main admin's key, not an epoch, is what decides.

#### Posts and submissions

- Rule 3 becomes: a post to the whole group is accepted from the main admin or any admin.
- A submission (type 37) goes to **every admin**. The first to approve re-posts it. Type 36 gains
  the optional key `6: submission id (16 bytes)`, so the other admins drop it from their queues.

#### Readers first

v1 phones refuse `v = 2` and an unknown key 6 in type 36. So a card advertises `fga` once its
build reads v2. A group stays v1, and "Add Admin" says why, until **every** member's card
advertises `fga`. A v2 group then sends only v2.

### 5.25 Formal groups on MLS, one copy on the relay (`payloadType 38`-`43`, `J1682`)

**Status, 27 September 2026: DESIGN, from the owner's answers of the same night (`DECISIONS.md`,
"Formal groups, redefined by the owner"), revised after its external review (verdict GOOD: the veto, per-
admin steward keys, the proposal rules and the certified MLS key all came from it). It replaces
§5.24's hub for new formal groups; §5.24 groups keep working as they are until they are moved.**
*28 September 2026: the core (member, settings, the six commit rules, the four joining bodies, the
post, the steward statement), the relay's `v1/group`, and the iOS engine are built and tested
against an in-memory relay.* *30 September 2026 (`J1800`): every new formal group on both apps is
one of these (`MLSGroups.creates`), and pseudonymous (`createsPseudonymousGroups`, on since 2.881).*

Formal and informal are two kinds a creator chooses, not two sizes: a formal group may have two
members, an informal one up to eight.

#### 5.25.1 What each party holds

| | holds |
|---|---|
| a member's phone | the MLS group state (RFC 9420): the ratchet tree, the epoch secrets, the group context |
| the relay | per group, one **mailbox**: each message once, per epoch; the hash of the current epoch's access key; the hashes of the **steward keys**, one per admin, the main admin's marked; and the notify ids of members who turned on wakes (5.25.6). No member list, no names, no identities |
| nobody | the content of any message, or who wrote it |

**MLS through OpenMLS** (MIT), in the core, cipher suite `0x0003`
(`MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519`): the primitives §4.1 already uses, nothing
new. A member's MLS credential is a `BasicCredential` whose identity is the member's 32-byte CHANNEL
identity. **Its MLS signature key is a separate Ed25519 key, one per profile, certified by the
CHANNEL identity**: an Ed25519 signature by the identity over `"channel/mls-signature-key/v1" ||
mlsSignatureKey`, carried in a leaf-node extension (`0xF0C2`). Every phone checks the certificate of
every leaf it meets, on joining and on every Add and Update. *Measured 27 Sep 2026 in a probe crate:
OpenMLS 0.9 accepts a raw Ed25519 seed as a signature key, joins, exports and removes as this
section needs. The first draft reused the identity key itself; the external review of this section
(27 Sep 2026) argued, and this adopts, that a separate certified key keeps an MLS signing bug from
being a CHANNEL forgery and lets the MLS key rotate without the identity.*

**Per-group pseudonyms (`J1694`, 28 Sep 2026, the owner's approval; `PER-GROUP-IDENTITIES.md`).**
A group created from this build on lists capability `0xF0C5` as **required**, from creation and for
ever (rule 3 refuses a settings commit that drops it). In such a group a member's credential identity
is a pseudonym and its MLS signature key is the group's own, both derived and never stored:

```
PRK  = HKDF-Extract(salt = zeros, IKM = mls_seed)                         once per profile
p_g  = HKDF-Expand(PRK, "channel/group-pseudonym/v1" || u32be(len g) || g, 32)
k_g  = HKDF-Expand(PRK, "channel/group-signing/v1"   || u32be(len g) || g, 32)
leaf = BasicCredential(Ed25519Public(p_g)), signature key Ed25519Public(k_g),
       certificate Ed25519Sign(p_g, "channel/mls-signature-key/v1" || Ed25519Public(k_g))
```

So no leaf carries a CHANNEL identity or the profile's MLS key, and one profile's leaves in two
groups share nothing. The same profile rejoining a group has the same pseudonym, which is what lets a
ban hold. The creator chooses the group id first and the settings' main admin is its pseudonym. An
older build's KeyPackage lacks `0xF0C5` and is refused at the Add, before the tree changes, so an
older build cannot join such a group (it can still join a classic one). A group created earlier keeps
CHANNEL identities; nothing converts it.

**Who is who, and how a phone learns it.** The pairwise bodies (5.25.4) travel between CHANNEL
identities, while the settings and the tree name pseudonyms, so a phone ties a contact to a member only
where the tie is proved, and nowhere else:

- **The admitting admin** takes the joiner's pseudonym from the KeyPackage in the contact's GroupJoin
  (type 39). It cannot check that pseudonym against the contact, since a modified client can choose any,
  so it asks the ban list about both the contact's identity and the pseudonym before adding.
- **The joiner** takes the admitting admin's pseudonym from the Welcome's signer (`GroupInfo.signer`),
  because the Welcome arrived pairwise from that contact.

Nothing else is learned, and no phone passes on what it learned. A GroupCheck (42) counts only from the
contact whose member is the main admin, and a GroupSteward (41) only from a contact whose member is an
admin. So in a pseudonymous group, the main admin's checks reach the members it admitted, and a
co-admin can register a steward key only with a main admin that admitted it. A moderated post
(5.25.3) names its author's pseudonym, never the contact's identity. Remove and Ban bans both the
pseudonym and, where known, the contact's identity.

**Proofs (`J1698`, 29 September 2026).** The pairing above is proved, not assumed: a contact in the
middle could otherwise forward one person's KeyPackage as its own answer and hand the admin's Welcome
back, and each phone would bind the middle contact to the other's pseudonym. So in a pseudonymous
group:

- A **GroupJoin** carrying a KeyPackage carries **key 2, a 64-byte join proof**: Ed25519 by the
  joiner's pseudonym key over `"channel/group-join/v1" || u32be(len g) || g || joiner's CHANNEL
  identity || invitation id`. The admin verifies it against the KeyPackage's credential identity,
  with the GroupJoin's authenticated sender as the identity, **before** the Add. Absent or wrong:
  dropped.
- A **GroupWelcome** carries **key 5, a 64-byte admit proof**: the same shape under
  `"channel/group-admit/v1"`, by the pseudonym that committed the Add, over that admin's CHANNEL
  identity. The joiner verifies it against the Welcome's signer (`added_by`), with the Welcome's
  sender as the identity. Absent or wrong: the join stands (the Welcome is genuine), but no pairing
  is recorded, so nothing that contact sends is taken as the admin's.
- A pairing once recorded is never re-pointed: a contact already bound to one pseudonym is not
  bound to another.

A proof is **transferable**: whoever holds it can show a third party that this identity is this
pseudonym in this group. Only the admitting admin and the joiner hold one, and each already knows
the other. The pairwise channel is not claimed to be deniable either: every message on the
initiator's and the responder's first sending chain of a ratchet session carries an Ed25519 identity
signature over its own ciphertext (§4.5a, signed starts), which whoever holds the message can show to
a third party. Classic groups need no proof, since a
KeyPackage there must name its sender.

**Capability `fgp`** on a card says the build reads invitation key 5 and joins under a pseudonym. An
admin invites a card without it into no pseudonymous group.

#### 5.25.2 The mailbox

```
mailbox     M      = SHA-256("channel/group-mailbox/v1" || MLS group_id)
access key  A(e)   = MLS-Exporter("channel/group-mailbox/access", M, 32)   in epoch e
steward keys S_i   = one Ed25519 key pair per admin, made on that admin's phone. The relay holds
                     only SHA-256 of each public key, and which one is the main admin's.
```

Operations (`v1/group`), every one refused whole on any failed check:

- **create** `{M, H(A(0)), H(S_main.public)}`, once. Write-once.
- **post** `{M, e, A(e), blob}`: accepted only when `e` is the relay's current epoch for `M` and
  `H(A(e))` matches. Stored under `(M, e)` with an arrival stamp. A blob is an MLS
  `PrivateMessage`, at most 64 KB (larger media goes by §5.23a's upload-once address, whose key
  travels inside the message).
- **collect** `{M, e, A(e), after}`: the blobs stored under `(M, e)` after a stamp, and, once the
  epoch has ended, the commit that ended it. **A key collects only its own epoch.** A phone that is
  behind catches up one epoch at a time: it collects epoch `e`, processes the commit, derives
  `A(e+1)`, and goes on. A removed member collects up to and including the commit that removed it,
  and can derive no key after that.
- **advance** `{M, e, A(e), commit, H(A(e+1)), stewards', signature by S_i over (M, e, H(commit),
  H(A(e+1)), H(stewards'))}`: accepted only when `e` is current, `A(e)` matches, the commit parses as
  an MLS `PrivateMessage` of content type commit for epoch `e`, and the signature verifies under a
  registered steward key. `stewards'` is the steward set after this commit; **the relay refuses a
  change to it unless `S_main` signed**, so only the main admin names or removes admins, and removing
  an admin removes their steward key in the same advance that removes them. **Write-once per
  `(M, e)`, one slot for the whole group** (R2 `PutObject` with `If-None-Match: *`, which R2 answers
  412 when the object exists, per Cloudflare's R2 documentation), and every reader is served the
  same stored bytes. When two admins commit at once the relay decides, and the loser rebuilds on the
  winner. MLS needs a single order of commits; this is where it comes from.
- **veto** `{M, e, commit', H(A(e+1))', stewards', signature by S_main}`: replaces the commit that
  ended epoch `e`, only while `e + 1` is the newest epoch. For a commit the relay accepted and every
  phone refused under 5.25.3 below: since no phone merged it, replacing it forks nobody. Only the main
  admin can do it.

  **The veto names what it replaces (`J1683`).** Signed over `"channel/group-veto/v2" || M ||
  u64be(e) || SHA-256(replaced) || SHA-256(commit') || H(A(e+1))' || SHA-256(stewards')`, where
  `replaced` is the commit the main admin refused, exactly as collected; the relay takes it only if
  the commit it holds for `e` hashes to it, so a captured veto cannot be sent again once the slot
  has changed. The v1 statement (without `replaced`) is still accepted while older apps are in use,
  and refused once both platforms signing v2 are installed.

  **Every commit knows its parent (`J1683`).** The relay records, with each commit it stores, the
  SHA-256 of its body and of the record it was built on (a genesis body is empty). A commit whose
  parent is not the record before it -- a co-admin's advance of `e + 1` that landed while the main
  admin was replacing `e` -- is an orphan: not part of the chain, not the end of an epoch, never
  served, and deleted by the veto that orphaned it. This is the relay's own bookkeeping; no phone
  sends a parent.

The relay learns: that a mailbox exists, how many messages each epoch holds and their sizes, when
they arrive and are collected, and from which addresses; and, from MLS's own clear framing, each
message's epoch and whether it is a commit. Not who is in the group, not how many, not what
anything says -- except that a member who turns on wakes (5.25.6) is a notify id held against `M`,
joinable to that phone's push registration, for as long as it follows.

**What a hostile relay can do**, stated rather than hoped away: withhold, delay or drop messages;
refuse advances, freezing membership; and show different members different histories by withholding
a commit from some. It cannot read a message or forge a member. A withheld commit is detected when
two members compare MLS's `epoch_authenticator` over their pairwise channel, which each admin's
phone does with each member it is linked with on every advance it makes; a mismatch is shown, not
repaired silently.

**The relay's current epoch is the proof that a group is current**, which a charter could not give
(external review of `J1680`, 27 Sep 2026): a stale fork of a group, made by somebody who still holds an
old epoch, can neither post to nor collect from the real mailbox.

#### 5.25.3 Who may do what

The group's settings live in a group-context extension, `channel_group` (type `0xF0C1`, a private-use
value), canonical CBOR: `{0: name, 1: mode (0 everyone, 1 moderated, 2 admins only), 2: main admin
(identity), 3: admins (identities, at most 8, besides the main admin), 4: listed (bool), 5?: handle,
6: stewards (the SHA-256 of each steward public key, sorted, the main admin's included; at most 9),
7?: bans (the ban hash of each banned identity, sorted, at most 1024), 8?: join policy (uint)}`.
*Key 6 added 28 Sep 2026, before any group shipped:* the steward set is group state, so it lives
where MLS authenticates it. Every admin signs an advance over the set the commit leaves in the
settings, and needs nothing from the relay to know it; only the main admin changes settings (rule 3),
which is exactly the relay's rule that only `S_main` may change the set.
MLS guarantees every member holds the same group context, so every member agrees on these.

*Key 7 added 28 Sep 2026 (`J1691b`, the owner's approval):* **bans.** A ban hash is
`SHA-256("channel/group-ban/v1" || u32be(len(group id)) || group id || identity)`: it hides who is banned
from a member who does not already hold the identity, and one who does can test it, which is
accepted -- the list exists for admins' phones to consult. Key 7 is **absent while empty**, so a group
with no bans encodes byte-for-byte as it did before. Readers first: a build that reads key 7 lists
capability `0xF0C4` in its leaf, the main admin writes a ban only once **every** leaf lists it, and a
group with bans lists `0xF0C4` in its **required capabilities**, so an older build's KeyPackage is
refused at the add rather than joining a group whose settings it cannot decode. A phone whose own leaf
does not list everything its build reads refreshes it (5.25.9). Only the main admin changes the list
(rule 3), and the apps' settings never carry it: a settings change keeps the list the group holds.
*Key 8 added 30 Oct 2026 (`J3634`, the owner's decision; `designs/J3634-join-policy.md`):* **who may
join**, chosen by the main admin and independent of the posting mode: 1 open (anybody with the group's
handle joins at once), 2 invite only (only people an admin invites); **absent is approval** (a request
at the handle or link waits for an admin), which is what every group before it does, so such a group
encodes byte for byte as before. A present 0 is not canonical and is refused; **any other value is read
as invite only** and kept as it travelled. Readers first, exactly as key 7: a build that reads key 8
lists capability `0xF0C7` in its leaf, the main admin writes it only once every leaf lists it, a group
whose settings carry it lists `0xF0C7` in its required capabilities (rule 3 refuses settings with key 8
that drop it), and a settings change keeps the policy the group holds. What the main admin's phone does
with a request to join is the core's (`channel-algo` `group_join`): join now, ask an admin, or refuse.
**Handover** (`J1723`, 29 Sep 2026, the owner's approval): the main admin may pass the role to an admin, and must before leaving -- a group is never left without one. It is a settings commit that swaps the two (rule 3), made only once every leaf lists capability `0xF0C6` (reads a handover), so no build that would refuse it ever sees one. The advance that carries it is signed by the CURRENT main steward over `"channel/group-handover/v1" || M || u64be(e) || SHA-256(commit) || H(A(e+1)) || SHA-256(stewards) || new main steward hash`, and names that hash (`main`), which must already be in the steward set; the relay moves its `main` on this and on nothing else. After it the old main admin is an ordinary admin, and leaves like one: a Remove of its own leaf, which the new main admin commits. If no other admin exists, the main admin names one first (5.25.4 step 4) and hands over once that admin's steward key is registered.
**Unban** (`J1722`, 29 Sep 2026): the main admin removes one hash from key 7 in a settings commit, and key 7 leaves the encoding again when the last one goes. Lifting a ban re-adds nobody. The list holds only hashes, so the main admin's phone keeps its own record of whom it banned, and under what name, to show a Banned list. No other phone can name the people on it. A leaf lists exactly what its build reads, so a phone that reinstalled an older
build refreshes its leaf and stops promising a reader it lacks. Removing an admin takes the role (and
its steward hash, key 6) away first, so the relay stops accepting that person's advances.

- **Commits are the admins'.** Every commit is posted by an admin (it needs a steward key). Members'
  own changes, leaving or refreshing their keys, are proposals an admin commits, which is also why
  post-compromise refreshes happen only when an admin is online.
- **Every phone checks every commit before merging it**, in the core, against the resolved proposal
  list (inline and by reference), and refuses the whole commit if any of these fails:
  1. no `ExternalInit`, `PreSharedKey` or `ReInit` proposal, and no external commit;
  2. a proposal by reference only for a member's own `Remove` or `Update`, sent in this epoch;
  3. `GroupContextExtensions` only from the main admin, carrying exactly one `channel_group` and no
     `external_senders`; the main admin (key 2) changes only in a **handover** (`J1723`): the new main
     admin is in `admins` already, the old one takes their place in `admins`, and nothing else in
     the settings differs -- the steward set included;
  4. no `Update` or leaf that changes a member's credential identity or MLS signature key, and a
     valid certificate (5.25.1) on every leaf added or updated;
  5. no `Remove` of an admin or of the main admin unless the main admin commits it, and never the
     last steward;
  6. every `Add` names an identity that accepted an invitation (5.25.4), is not already a leaf, and
     is not banned by the settings the commit starts from (key 7) -- whichever admin commits it;
  7. settings carrying a ban list keep `0xF0C4` in the required capabilities.
  A commit refused here is not merged, the epoch's secrets are kept, and the main admin's phone,
  seeing it, vetoes it (5.25.2). **OpenMLS's `process_message` succeeding is not acceptance**: the
  staged commit is inspected against these rules before `merge_staged_commit`.
- **Posting**: in *everyone*, any member posts to the mailbox. In *admins only*, a receiver drops a
  message whose MLS sender is not an admin. In *moderated*, a member's post goes pairwise to the
  admin who added them (type `43`, `{0: MLS group id, 1: post id (16 bytes), 2: text}`), and an admin
  who approves it posts it to the group naming the author (the post's key 4, §5.25.9); a receiver
  credits that author only when the MLS sender is an admin, and the author's own phone, which filed
  the words when it sent them, does not file them again. **This is the only case a post passes
  through an admin**, which is the owner's rule.

#### 5.25.4 Joining

1. **Invitation**, pairwise from an admin (new type `38`): the group's name and mode, and the
   admin. The person taps **Join**, or does not.
2. **Join** answers with a KeyPackage (type `39`), pairwise to that admin.
3. The admin commits the Add and sends the **Welcome** pairwise (type `40`), with `M`. The new member
   joins at the current epoch and can read nothing sent before it.
4. The main admin names an admin in a settings commit. The new admin's phone, on merging it, makes
   its own steward key and sends the public half pairwise to the main admin (type `41`), whose phone
   adds its hash to the settings' steward set in a second settings commit -- the advance, signed by
   `S_main`, that registers it at the relay. Removing an admin removes them from `admins` and their
   hash from `stewards` in one settings commit.

A request to join, from a handle or a link (`J1679`), reaches an admin as a first
contact; accepting it is step 1.

**The four bodies**, pairwise, sealed like any message, canonical CBOR, strict (the listed keys
only, canonical order, no trailing bytes), each naming the invitation it belongs to so an answer can
never be applied to another:

| Type | Body |
|---|---|
| `38` GroupInvitation | `{0: invitation id (16 bytes, random), 1: MLS group id (at most 32 bytes), 2: name (text, at most 48 bytes), 3: mode (0 everyone, 1 moderated, 2 admins only), 4: main admin (32-byte identity -- the main admin's pseudonym in a pseudonymous group), 5?: 1 when the group is pseudonymous (`J1694`), so the joiner mints its KeyPackage for this group}`. From an admin. The name and mode are a preview only: what the member agrees to is what the Welcome's group context says, and `join` refuses a Welcome whose main admin is not key 4. |
| `39` GroupJoin | `{0: invitation id, 1?: KeyPackage (bstr, at most 16 KB), 2?: join proof (64 bytes, only with key 1, pseudonymous groups, `J1698` 5.25.1)}`. To the admin who invited. Without key 1 it declines. A KeyPackage for an invitation this admin never sent, or already answered, is dropped. An invitation not answered within **90 days** lapses: the admin's phone forgets it, and a later answer is ignored. |
| `40` GroupWelcome | `{0: invitation id, 1: mailbox M (32 bytes), 2: current epoch (uint), and exactly one of 3: Welcome (bstr, at most 24 KB) or 4: media reference (§5.23a's `{addr, key, len, sha}`), 5?: admit proof (64 bytes, pseudonymous groups, `J1698` 5.25.1)}`. From the admin who invited. A Welcome carries the ratchet tree, which grows with the group, so a large group's Welcome travels sealed-once at an upload-once address and only its key goes pairwise. |
| `41` GroupSteward | `{0: MLS group id, 1: steward public key (32 bytes)}`. From an admin to the main admin, and only after the settings name the sender as an admin. |
| `42` GroupCheck | `{0: MLS group id, 1: epoch (uint), 2: epoch_authenticator (32 bytes)}`. From the main admin, after every advance its phone makes or merges, to each member it is linked with (5.25.2). Only the main admin's counts -- with several, a co-admin could overwrite an honest claim or silence the alarm; compared at once when the member is at that epoch, kept until it gets there when it is behind, and dropped when it has moved past. A mismatch is shown on the thread and never repaired silently. |

A phone reads these only when its card advertises `fgm`, and an admin sends them only to a contact
whose card does.

#### 5.25.5 (reserved)

This number is not used.

#### 5.25.6 Wakes and notifications

Each member chooses, per group: **Push** (the relay may wake this phone when the mailbox receives a
message; the relay then knows this phone follows `M`) or **Fetch** (collected on the ordinary
schedule, known to nobody); and separately whether a group message notifies at all. The group can
carry a contact card for its members.

**Following.** `POST v1/group?op=follow` with `{m, e, access: A(e), notifyId}` and the notify id's
secret in `x-channel-notify-secret` (§ `J1634`). The relay requires both: the access key proves
membership of epoch `e`, the secret proves the id belongs to the phone asking, so nobody can point a
group's wakes at a phone that did not ask; and the id must have a live registration (`409 {reason:
"unregistered"}` otherwise), so a member cannot fill the group's slots with ids that name no phone.
Follows and unfollows are rate-limited per address (60 an hour). It stores `M/notifyId -> {until}` and nothing else;
`until` is seven days ahead, and a phone that still wants wakes renews once a day. `op=unfollow`
takes `{m, notifyId}` and the secret alone, because a phone that has left no longer holds the
access key. At most **512** followers per group (`409 {reason: "full"}` after that): push wakes are
for small, important groups; a large busy one stays on Fetch. An expired follow is swept hourly.

**Waking.** After a `post` is stored, the relay wakes every live follower with a **quiet** push,
unless it already woke that group within the last **60 seconds** -- one stored timestamp per group,
so a busy group is one wake a minute, not a stream. The store has no compare-and-set, so posts that
arrive together can each wake the followers once; the fan-out runs after the response where the
runtime allows it, so a large follower list never delays the poster. A lapsed follow does not
count against the cap. A registration in `periodic` mode is never woken
this way, the same rule as a deposit. A wake that fails never fails the post. The poster is not
known to the relay and is woken like everyone else if it follows.

**What it reveals.** The relay learns which notify ids follow which mailbox for as long as they do,
and joined with the registry that names each follower's push token.
A member removed from the group cannot be unfollowed by the admin, who never learns the id; its
follow lapses within seven days, and until then it tells that phone only *when* the group posted.

#### 5.25.7 Retention and size

The relay keeps each epoch's messages for **30 days**, then sweeps them. A phone away longer misses
them, and the thread says so.

**A group lasts as long as it is used.** The relay records when each group was last active -- a
post or a membership change; a group nobody joined counts from when it was made -- and hands that
time to a member collecting (`lastActivity`, epoch milliseconds). After **three months** without
activity every admin's phone says the group will be deleted, and when; after **four months** the
relay deletes the whole group: its genesis, every commit record and anything else in its mailbox,
its epoch pointer and its follows. Any post resets the clock. A member's phone collecting a deleted group is answered
`404 {reason: "gone"}` -- the relay tells only whether a mailbox exists, which `create` already
answers to anybody -- and marks the group deleted, stops collecting it and forgets what it held for
it, its steward key above all. (The owner, 28 Sep 2026; first six and seven months, shortened the same day because any post extends it.) The time is
recorded by the relay's hourly sweep, which sees every message before its 30 days are up, so it is
right to within an hour.

MLS keeps each member's work per change at `log2(n)`; a 100,000-member
group's ratchet tree is a download of some megabytes, fetched once on joining. Measured before it is
promised: nothing in this section is a claim about a size until a test of that size has run.

#### 5.25.10 A member's report (`J1686`)

The owner, 28 September 2026 (`DECISIONS.md` `J1686b`): **only a proven member's report makes the
relay preserve a group's records, for a year; only legal process stops a group, never a report.**

**`POST v1/group?op=report`** `{m, e, access: A(e), reason, handle?, media?}`. `A(e)` for the relay's
current epoch is the proof: it is what a member collects with, and nobody outside the group holds it.
`reason` is one of 1 spam, 3 harassment, 4 impersonation, 5 other, **6 child sexual exploitation**, 7 other illegal content (`J1800a`). **2** is what builds before `J1800a` sent for child sexual exploitation and other illegal content alike: no build sends it now, the relay still accepts it, and it is recorded as *illegal, unspecified* -- possibly child sexual exploitation, never read as a report that it is not. The table is `server/netlify/lib/_report-reasons.mts`, twinned in both apps' `GroupReportReason` and held together by `scripts/check-shared-tables.py`. `handle` is the
group's handle when it has one; it links this mailbox to that handle **in the report only**, which is
the one place the link is needed. **It is the reporter's claim**: the relay cannot bind a handle to a
mailbox, so the owner checks it before acting on the handle. `media` is at most **256** upload addresses of photos the reporting phone has seen in
this group; the relay cannot tell a group's media from anybody else's, so it keeps only what it is
named -- which means a member can also name media of other groups whose addresses they know, and
extend its keeping by a year; bounded at 256 a day per group, and no more than that member could keep
by downloading it.

**What the relay does.** It copies, as they are at that moment, every object it holds under the
group's mailbox -- its genesis, its commit records, every message still within its 30 days, at most **5,000** objects
**[policy]**, the record saying when it stopped short -- and each named media object that exists, to a
preservation area the sweeps do not touch, and records `{at, epoch, reports, reasons, categories,
cse?, possibleCse?, handle?, objects, media}` for the owner to read: `categories` names the reasons, `cse` is set when any report that day chose 6 and `possibleCse` when any chose 2. `scripts/group-reports.mts` is where the owner reads them, CSE first; it is read-only and files nothing. The record is written before the copy starts, so a copy the
runtime cuts short is still described. Nothing about who reported is
kept. **Nothing about the group changes**: its messages are swept on their usual schedule, it is
deleted when idle as before, and no member can observe that a report was made. A copy is kept for
**365 days** and then deleted, unless the owner, under legal process, keeps it longer.

**Bounds.** One address may report 10 times an hour **[policy]**. A group is copied at most once a day
**[policy]**, and has at most one record a day: a report inside that day is **counted on that day's
record** and copies nothing new. It is never refused for being one too many -- a cap that answered
and kept nothing would let one member silence every later report (external review, 28 Sep 2026). A report that fails any
check is refused whole, the same answer as any other refusal.

**Afterwards.** The owner reads the reports. **A report of child sexual exploitation** (`cse`, and
any `possibleCse` the owner judges to be one) is filed with NCMEC's CyberTipline -- which group, when,
that we are preserving it, the handle if there is one -- because 18 USC 2258A requires it on actual
knowledge of apparent child sexual exploitation. Other reports are kept for the year and are not filed
with NCMEC (the owner, 30 Sep 2026, `J1800a`). Filing is a manual act by the owner, never automated.
The owner may retire the group's handle. The relay holds ciphertext it cannot read; the member who saw material reports it
to NCMEC from their own phone (report.cybertip.org) and deletes it, and the app says so at the moment
of reporting. The group is not told.

*`J1800`, 30 September 2026:* the public pages say a group's records are kept for a year on every
report, and that we report to NCMEC **when a report tells us of child sexual exploitation**, which
is the trigger 18 USC 2258A sets (actual knowledge of apparent child sexual exploitation). Two things
were open and are settled by `J1800a`: only a report of child sexual exploitation is filed (the
owner, 30 Sep 2026), and it has its own code, 6, so the record tells it apart.

#### 5.25.9 What a post carries

The plaintext of an MLS application message, canonical CBOR, strict like §5.25.4's bodies:
`{0: post id (16 bytes, random), 1: sent at (uint, seconds, the author's clock), 2: text (UTF-8, 1
to 16,384 bytes)}`. The id lets a phone recognise a post it has already filed; `sent at` is shown and
never used to order anything. MLS authenticates the author, so the post does not name one.

Adding a key later is a change every member must read first: an older member refuses the whole post
(strict decoding), and a refused post is indistinguishable from none. So a new key waits until the
group context says every member reads it, the same readers-first rule as §5.25.8.

**How a phone knows every member reads a key: the leaves say so.** An MLS leaf lists the extension
types its build supports, every member holds every leaf, and a leaf changes only through a commit --
so "who can read this" is group state MLS already authenticates, with nothing new on the wire.
A build that reads a post key lists a private-use extension type for it (the first: `0xF0C3`,
key 3, below). A sender puts that key in a post only when **every** leaf lists it; otherwise it
sends what the others can read and says so on its own screen. A member whose build gains a reader
sends its own Update proposal (rule 2 lets it), and the next admin commit carries the new leaf.
*Built 28 Sep 2026: core, and iOS for photos.*

**Key 5, a card (`J1694`).** `5: name (UTF-8, 1 to 48 bytes)`, with key 2 empty and no key 3 or 4:
the name the sender chose to be known by in this group. A receiver takes it as naming the MLS sender
only -- nobody can name anybody else -- and keeps the newest per member. A card is sent only in a
pseudonymous group, where capability `0xF0C5` is required of every leaf; a build lists `0xF0C5`
only if it reads key 5, so that requirement is this key's readers-first rule. A decoder that reads
posts refuses a card, and one that reads cards refuses a post, so a card is never filed as an empty
message.

**Key 4, author** (moderated mode): the 32-byte identity whose words an admin is posting. **Key 3,
media, behind `0xF0C3`** (which also covers key 4 -- no build listed it before both existed): `{0: kind (1 photo, 2 video, 3 file, 4 voice note), 1:
address (16 bytes), 2: key (32 bytes), 3: length, 4: sha (32 bytes), 5?: file name (at most 255
bytes)}` -- §5.23a's upload-once object, sealed once for the whole group, its key inside the MLS
ciphertext. The relay keeps group media as long as the posts, 30 days. With key 3 present, key 2
(the text) may be empty.

**Key 6, forwarded (`J3753`, 1 Nov 2026).** `6: true`, the CBOR true and nothing else: the sender marks this
post as a copy of words written for somebody else and sent on, the same one-bit mark as a pairwise message's key
24 (§5.1e). It names no original sender, time or group. Absent reads as not forwarded; false or any other value
is not canonical and the whole post is refused, as for every key here. It follows every lower key in canonical
order and may sit beside key 3 (a photo) or key 4 (an admin's repost); a card (key 5) is never forwarded.
**Readers first, as above:** a build that reads key 6 lists a private-use extension type in its leaf, `0xF0C8`
(`FORWARDED_POSTS_CAPABILITY`), a sender writes key 6 only when **every** leaf lists it, and otherwise sends the
post without the mark (an older member would refuse the whole post). A group does not require `0xF0C8`, so an
older build still joins. Every leaf of a current build lists it, so a build that gains the reader announces its
leaf once (the existing refresh, §5.25.9 above) and the mark shows in the group from the next post. `vectors/71-group-post-forwarded.json`
pins the bytes, the post without the mark, a photo with it, and the two refusals. A moderated group's member
submission (type 43, §5.25.3) has no mark: the admin's repost of it is not marked forwarded.

#### 5.25.8 Readers first

Types 38 to 41 and `v1/group` are new. An admin invites only contacts whose card advertises `fgm`.
A §5.24 group moves to §5.25 when its admin chooses, once every member advertises `fgm`.

### 5.26 Stickers (`J1747`) -- a PNG that says so inside itself

A sticker is **an ordinary picture attachment**: `mediaType = 2` (PNG), sent and received exactly as
a photograph is. Nothing in any payload, chunk or envelope changes, and no payload type, map key or
capability is added. What makes it a sticker is one ancillary chunk inside the PNG:

```
u32 BE   length      1 ..= 65
4 bytes  type        "chSk"  (0x63 0x68 0x53 0x6B)
u8       version     1
bytes    id          length - 1 bytes of ASCII; the sender's sticker id, informational
u32 BE   crc         CRC-32 over type and data, as for every PNG chunk
```

placed **immediately after `IHDR`**. The id is never shown and nothing depends on it.

**Why inside the picture and not a payload key.** The requirement is that a sticker displays on
every build already in the field. An attachment chunk refuses unknown keys (§5.6 -- six or seven,
nothing else), so a flag there would make an old build refuse the sticker outright; a new
payload key would be skipped by an old build (§1.2) but would need the core, its FFI and both
apps' assembly paths to carry it for no gain. The PNG format already has the property wanted:
a chunk whose first letter is lower case is *ancillary*, and a decoder that does not know one
skips it (PNG specification, *Chunk naming conventions*). The second lower-case letter marks it
private, the upper-case third is the reserved bit every chunk must leave clear, and the lower-case
fourth says an editor may copy it.

**A receiver built before this section** stores the bytes and shows a 160-pixel picture as a
photograph. Nothing is lost and nothing is refused, so **no reader has to ship first.**

**A receiver built after it** draws the picture as a sticker -- large, with no bubble -- when all of
these hold, and as a photograph otherwise:

- the media type is 2 and the bytes begin with the PNG signature;
- a `chSk` chunk appears before the first `IDAT`, with a length of 1 to 65, version 1 and a
  correct CRC -- a receiver walks at most 64 chunks and never reads past the bytes present;
- the picture is at most 512 pixels on each side, so the marker cannot turn a photograph into
  something drawn without its frame at an unexpected size.

**The claim is the sender's, and it decides presentation only.** A sender who marks a photograph
gets a photograph drawn without a bubble; that is all the marker can do. It is inside the AEAD with
the rest of the attachment (§5.1), so nothing between the phones can add or remove it.

**What the sender sends.** Both apps keep their stickers as WebP (`scripts/build-stickers.py`,
Microsoft Fluent Emoji under MIT, credited in the help page *Third-Party Notices*) and make the PNG
at the moment of sending: decode, encode as PNG, insert the chunk. The marker is written and read by
twin implementations (`StickerMarker` on both platforms) held to one shared byte fixture.

*Added 29 September 2026 (`J1747`).*

### 5.27 View-once text (`payloadType 48`, `J1879`)

A text, or a shared location (`loc:1;` token, §5.13), that the recipient may read **once**. The
body, map keys and every rule are exactly those of `payloadType 1`: UTF-8 `body`, `messageId`
(key 10), optional `replyTo` and timer, compression and fragmentation as for text. Only the type
differs.

**Why a type and not a flag.** A picture's view-once is announced by a policy update naming the
content hash (§5.5), sent before the slices. That cannot work for text: the announcement and the
text are separate envelopes that may arrive in either order, and a text that lands first is shown
and stored in the clear. A flag inside the text's own payload (a policy key) fails the other way: an
older build skips the unknown key and shows the text as ordinary. **An unknown `payloadType` is
refused at decode** (§1.2), so a build that cannot honour view-once drops the envelope and shows
nothing. The failure is a message that does not arrive, never one that is kept.

**Capability `vot`.** A build that implements this section advertises `vot` in its card's `caps`
(§5.10). A sender offers view-once for text and location only to a contact whose card carries it,
and otherwise tells the person their contact's app must be updated first, instead of sending
something that will silently vanish. Groups: not offered (a group may hold members without `vot`).

**Pictures, clips and voice notes are not offered to a group either** (`J2871`). Their view-once is announced by a
POLICY_UPDATE naming the content hash (§5.5), which a receiver applies to a one-to-one thread only; the slices of a
group picture are filed in the group thread (§5.6e), where a view-once hash is refused (`J1774`). So nothing would cover
the picture, and every member would keep it as an ordinary file. A sender queues no view-once item for a group, and one
already queued is never sealed.

**Receiver.** Stores the body only encrypted under a fresh per-message key (the picture's
at-rest scheme), shows a bubble that says what kind of item it is and nothing of its content,
reveals it once on an explicit open, and on close deletes the key and the ciphertext and sends
nothing further. While it is open the screen is protected from capture where the platform allows
(Android `FLAG_SECURE`; on iPhone the content is drawn in a secure layer and hidden while the screen
is being captured). A camera pointed at the screen is not prevented, and the help page says so.

**Sender.** Keeps no readable copy: the sender's own bubble shows the kind and the delivery state,
never the text. Search, export, backup, reply quotes, notifications and the chat list preview never
contain the body on either side (the notification says only that a view-once message arrived).

### 5.28 AI authorship (`aiAuthor`, payload key 16, `J1960`)

The owner's decision (J1954 revision 5): **a message an AI wrote is shown as such on both phones,
always** -- in the colour the AI's owner chose for it (yellow by default) and with a spoken label.
It cannot be switched off by the sender's owner.

- **Sender.** Key 16 is set on a text payload (`payloadType` 1, 13 or 48) whose text came from an AI:
  a send through the local API (`J1880`, `J1954`) or CHANNEL's own responder. Its value is the
  owner's colour for that AI as `0xRRGGBB`. Nothing else is set from it.
- **Decoder.** A uint above `0xFFFFFF` is malformed and the payload is refused, as for any field of
  the wrong width (§1). An older decoder skips the unknown key (§1.2), so the text arrives unmarked:
  that is the cost of a reader that predates this, and why readers ship first.
- **Receiver.** Draws the message **bubble** in the colour **snapped to the nearest of its own
  palette**, with white text -- the value is hostile input, and a colour that white text cannot be read
  on must not be honoured as given -- and gives it the spoken label *"written by an AI"*. The palette
  (`AIColour`, identical on both apps) is nine colours, one per AI while a device holds at most ten
  profiles and never links the first: amber, orange, red, pink, purple, indigo, teal, brown, slate,
  each carrying white text at 5:1 or better (WCAG 2 asks 4.5:1), and none iMessage blue or the
  text-message green, so an AI's bubble cannot pass for the owner's own. A newly added AI is given the
  first colour no other AI on the device has.
  On any payload type other than 1, 13 or 48 the key is ignored.
- **What it is, plainly.** The sending app's statement. A modified client can leave it off, and
  nothing on the receiving phone can tell; the help page says so. It is inside the AEAD, so nobody
  but the two phones learns it.
- **Rollout, readers first (§6.7.5's order).** Step 1: every build decodes key 16 and draws it. Step 2,
  once step 1 is what contacts run: writers set it. There is no switch for the reader.

### 5.29 Link preview (`linkPreview`, payload key 17, `J1966`)

`DECISIONS.md` `J55`: **the sending phone fetches the preview; the receiving phone never contacts the
site.** A receiver-side fetch tells whoever controls a link the reader's address, rough location and
the moment the message was opened, so a link would work as a tracking beacon anybody can send. The
sender already chose to share the page; its own fetch is the disclosure it opted into.

Key 17 is a CBOR map with integer keys, canonical per §1:

| key | name | type | bound |
|---:|---|---|---|
| 1 | `url` | tstr | at most 2048 bytes; `https` only |
| 2 | `title` | tstr | at most 480 bytes (120 characters as the sender cuts it) |
| 3 | `summary` | tstr | *Optional.* At most 960 bytes |
| 4 | `site` | tstr | *Optional.* At most 120 bytes; the page's own site name |
| 5 | `image` | bstr | *Optional.* A baseline JPEG, at most 32,768 bytes, at most 600 pixels on its long side |

- **Sender.** Only on a text payload (`payloadType` 1 or 13), only when previews are on for the
  profile, and only for the first `https` link in the text. It fetches the page with no cookies and no
  cache (the bounds `J1675` set: 256 KB of page, 6 s), reads Open Graph then Twitter tags then
  `<title>`, and fetches `og:image` under the same limits. The image is **decoded and re-encoded on
  the sender** to a JPEG within the bounds above, so remote bytes are never passed on as received.
  Nothing is sent when the page has no title.
- **Decoder (the core).** Every bound above is checked before allocation; a map with an unknown key,
  a wrong type or a value past its bound makes key 17 malformed, and the payload is refused as for any
  malformed field (§1). An older decoder skips the key (§1.2): the text arrives as it always did.
- **Receiver.** Shows the preview **only if `url` is exactly the first `https` link in the message's
  own text**, so a preview can never name a destination the reader cannot see. Before handing `image`
  to the platform decoder it checks the JPEG start marker and the frame header's dimensions against the
  bounds; anything else drops the image and keeps the rest. It never requests `url` or anything the
  page names. Drawn inside the message bubble, above the text, at the size the other platforms' leading
  messenger draws it; tapping the preview opens `url` exactly as tapping the link does.
- **What it is, plainly.** The sender's claim. A modified client can attach any title and picture to
  any link, which is why the link itself is always shown and the preview must name it.
- **Rollout, readers first.** Step 1: every build decodes and draws key 17. Step 2, once step 1 is what
  contacts run: senders attach it.

### 5.30 Message edit (`payloadType 49`, `J1983`)

The owner, 4 October 2026 (`DECISIONS.md` J1982), reversing `J1485`'s delete-only stance: a sent
message may be edited, reached from its long-press menu or a right click.

**The author replaces the text of a message it sent.** The body is the 16-byte `messageId` (§5.1a,
key 10) of the author's own message, followed by the replacement text in UTF-8, 1 to 16,384 bytes.
Nothing else: no old text, no reason. It is pairwise, like a delete for everyone (§5.21): in a group,
each member is sent one. `vectors/26-message-edit-payload.json` holds the body rule, and both apps
read its `bodyCases`.

A sender offers it only on its own text message -- no attachment, no location, not view-once, not
a call record -- within 15 minutes of sending, and only when every recipient's card carries
capability `edt`.

A receiver acts on it only when **all** of these hold, and otherwise ignores it without answering:

- the body is a valid edit body (above);
- a message with that `messageId` exists on this phone **from the same sender** -- the authenticated
  sender of the edit is the message's own sender, exactly as §5.21 decides it;
- that message is plain text: no attachment, not view-once, not a location;
- the edit's own `sentAt` (§5.1 key 3) is no more than 15 minutes and 60 seconds after the
  message's time on this phone, and no more than 60 seconds before it.

Acting on it replaces the text and marks the message edited, which the bubble shows as *Edited*.
It raises no notification, changes no order or unread count, and nothing is sent back. A later edit
replaces an earlier one.

**Readers first.** Every build before `J1983` refuses payload type 49 as unknown, so an older phone
keeps showing the original text and never shows anything malformed. Capability `edt` is how a
sender knows not to offer the edit to it.

### 5.31 Typing and chat-open (`payloadType 50`, `J1988`)

The sender is typing in this conversation, or has stopped; or the sender has this conversation open
in the foreground, or has left it. **Ephemeral**: the receiver keeps it in memory for seconds and
stores nothing -- no message, no thread event.

**Body.** One signal byte, optionally followed by exactly 16 bytes: the informal group's `groupId`
when the signal is about a group thread. Any other length, or any other signal byte, is refused and
dropped. Pairwise only (`is_group` is false): in an informal group the sender seals one copy to each
member, as for a delete (§5.21).

| byte | signal | receiver |
|---|---|---|
| `0x00` | stopped typing | clears "typing" |
| `0x01` | typing | shows "typing" for 6 s unless refreshed; also counts as chat-open |
| `0x02` | chat open | the sender has this chat open in the foreground; valid for 25 s unless refreshed |
| `0x03` | chat closed | the sender left the chat, backgrounded the app, or locked the phone; clears both |

**The foreground gate.** A sender sends `0x01`/`0x00` only to a contact whose `0x02` is current. It
sends `0x02` when it opens the conversation or returns to the foreground with it open, and every
10 s while it stays so; `0x03` when it leaves, goes to the background, or the screen locks. So a
phone that is locked, backgrounded or elsewhere is never sent a typing notice. A chat behind its own
PIN (§ chat lock, `J1990`) sends no `0x02` until it has been opened with that PIN.

**Routes.** A sender puts these only on routes that touch no server: Bluetooth, Wi-Fi Direct, the
local network and the direct IPv6 link (`6`), each only to a peer already bound to that contact.
Never the relay mailbox, never a text message, never a stranger's phone. When none of those reaches
the contact, nothing is sent. The wire does not enforce this; it is the sender's rule.

**Cadence.** At most one `0x01` every 3 seconds while the person keeps typing, and one `0x00` when
the field is cleared or the message is sent.

**Reciprocal switch.** Per profile, with a per-contact override. Where it is off for a contact, this
device sends that contact none of these signals and ignores every one it receives from them.

### 5.32 Poll create (payload type 51, `J1986`)

Sent pairwise to the other person in a one-to-one chat, only to a card carrying capability `pol`.
There is no group id: a poll is one-to-one. Body, in order:

`pollId`(16) | `flags`(1; `0x01` = several answers allowed) | `closesAt` (u64 BE seconds; 0 = none) |
`optCount`(1) | `qLen`(u16 BE) | question (UTF-8) | (`optLen`(1) | option)\*

The question is at most 200 bytes; there are 2 to 12 options of 1 to 80 bytes each. A body with
trailing bytes, a short field or any bound exceeded is dropped whole. `pollId` is the sender's
message wire id. Vector 27 pins the layout.

### 5.33 Poll vote (payload type 52)

`pollId`(16) | `mask`(u16 BE; bit *i* set = option *i* chosen). Sent to the poll's other participant.
The newest `sentAt` per voter wins; an older vote never replaces a newer one. An empty mask retracts
the vote. A mask naming an option the poll does not have, a vote for an unknown poll and a vote for
a closed poll are dropped. A single-answer poll accepts at most one bit.

### 5.34 Poll close (payload type 53)

The 16-byte `pollId`. Accepted only from the poll's author. A poll also reads as closed once
`closesAt` has passed. Results are final after close; votes arriving later are dropped.

### 5.35 Mention tag (payload type 54, `J1987`)

Companion to a text message, which is sent unchanged: the body keeps its literal `@Name`, so every
build shows readable text. The tag names which group members those `@` spans mean, so each viewer can
show the member in their own vocabulary. Sent like a poll vote (pairwise in an informal group, an MLS
application message in a formal one), only to a card carrying capability `mnt`. Body, in order:

`messageId`(16) | `count`(1; 1 to 8) | (`memberId`(32) | `start`(u16 BE) | `len`(1))\*

`start` and `len` are in UTF-16 code units of the named message's body; `len` is 1 to 64. Spans are
strictly ascending and may touch but not overlap (each `start` is at least the previous `start` +
`len`). The core checks this shape only. A body with count 0 or over 8, a length that does not match
the count, trailing bytes, or any span out of bounds or order is dropped whole. The receiver also
checks each span against the stored body (in range, begins with `@`) and accepts the tag only from the
author of the message it names; otherwise the text shows as plain. Vector 28 pins the layout.


### 5.36 Evidence export signature (`J2597b`) -- not on the wire

A Report export (`J2597`) never travels between CHANNEL devices, but it is signed with the identity
key, so its tag belongs in this document's list of what that key signs. `signature.txt` in the zip
carries Ed25519 (RFC 8032, pure) over exactly 61 bytes:

```
"channel/evidence-manifest-v1\0" (29) | SHA-256(manifest.json bytes) (32)
```

Made only when the signature verifies under the manifest's `exporter.identity_key` (a pseudonymous
group names a per-group key, so its export is unsigned). **No CHANNEL verifier accepts this tag for
anything**: it signs caller-chosen content, and is for a person holding the file. `evidence.rs`.

### 5.37 Rolling presence tag (`J2626`, not a payload)

A profile holds one 32-byte presence secret `S` and sends it to each contact inside the encrypted contact
card as the optional field `prs` (base64; an absent `prs` is read as "none"). Its phones advertise, while the
app is open, `tag(S, e) = HMAC-SHA256(S, "channel/presence-tag-v1" || e as u64 big-endian)[0..8]` with
`e = floor(unix_seconds / 900)`. A reader builds one table per epoch from every secret it holds (a replaced
secret is kept while `now < retired_at + 900`) for `e-1`, `e`, `e+1`, and looks each sighting up; a lookup
takes exactly 8 bytes, or the first 6 (an iPhone's local name carries them as 10 RFC 4648 base32
characters, upper case, no padding); any other length is ignored, and a 6-byte tag two contacts share
matches nobody. `S` is replaced when a link is revoked or a contact deleted. **A sighting is a hint**: it may
put Bluetooth first for one capped attempt and feeds the route evidence; "nearby" counts as proven only
after a connection authenticates (`PRESENCE`). Vectors: `vectors/42-presence-tag.json`; code:
`mesh-core-rs/src/presence.rs`.

### 5.38 Group poll (payload types 56, 57, 58, `J2681`)

A poll in an informal group. Types 51 to 53 stay one-to-one and are unchanged: nothing new is sent on
them. Types 56, 57 and 58 are group types (`is_group` is true). They carry the informal group fields
the way a group text (type 13, §5.2.3) does, and a payload of one of these types without those fields
is refused. They are not invitations (§5.2.7) and not calls. Key 14 (a removed identity) is refused
on them, as on every type but 16.

**56 `GroupPollCreate`.** The body is §5.32's create body. **57 `GroupPollVote`.** The body is §5.33's
vote body. The payload carries those bodies as opaque bytes, the same way it carries types 51 and 52.
`poll::PollCreate` and `poll::decode_vote` check them, and so do the apps.

**58 `GroupPollClose`.** In order:

`pollId`(16) | `voters`(u16 BE) | `optCount`(1) | `count`(u16 BE) × `optCount`

`optCount` is 2 to 12, the same bound as §5.32. No count may exceed `voters`. A body that is short of
that length, a body with a trailing byte, an option count outside 2 to 12, or a count above `voters`
is refused whole. `voters` is a u16. The decider's limit of 1,024 voters is not a rule of this body.

The core stores no poll. It does not check that the sender is the poll's author, and it does not check
that `optCount` equals the option count of the poll this `pollId` names. Those are the app's checks,
applied when a close is filed. Type 53 stays the 16-byte `pollId` only.

Capability `gpl`: a build that reads types 56 to 58 says so on its card. A sender offers a group poll
only to a card that carries it. Vector 43 pins the layout. Formal groups are not this section: a formal
group poll needs its own application-message keys and a leaf extension, and those are not defined here.

### 5.39 Start fresh (payload type 59, `J1866f`)

A person whose old profile can no longer be trusted makes a new, empty profile and tells each contact.
The claim "this new profile replaces that identity key" is the **sealed first message of the ordinary v5
invitation** (§6.7), never a trailer on it: an unsigned trailer can be added by anyone who carries the
invitation, while a first message can only be produced by the new identity and is bound to that identity
and to this recipient by the invitation's AEAD. It proves who **says** it, never that it is true: a
thief holding a host of the old profile could make the same claim.

Type 59 is pairwise (not a group type, not a call) and is a first message only. Body, in order:

`version`(1; = 1) | `oldIdentity`(32) | `proofs`(1) | `textLen`(u16 BE; at most 2,000) | `text`(UTF-8)

- `oldIdentity` is not all zero and is not the sender's own identity (the one the invitation's key
  agreement authenticated).
- `proofs` is the sender's own statement: bit 0, the number or email was verified again on the new
  profile (the receiver checks the invitation's relay `verified` stamp, never this bit); bit 1, a
  verified video follows (reserved; zero until it is built). Any other bit is refused.
- The body is exactly `36 + textLen` bytes. A short body, a trailing byte, a version other than 1, an
  unknown proofs bit, a text over 2,000 bytes (checked from the length field, before the text is read)
  or text that is not valid UTF-8 is refused whole. Refusal codes 801 to 808 (`start_fresh::Error`).

The core stores nothing. Whether `oldIdentity` is a card the receiver holds, and whether two claims
name one key within 30 days, are the app's judgements. A build that does not know type 59 drops it as an
unknown first message and files no request. Vector 46 pins the layout; code: `mesh-core-rs/src/start_fresh.rs`.

### 5.40 Command keys (payload key 23 and payload type 60, `J2806`)

**Problem.** An AI attached to a profile reads the messages sent to it. A message that says "I am the owner"
proves nothing: anyone in the chat can type it. A command key lets the AI's app tell a message from a person the
owner chose from a message that only claims to be.

**The key.** 32 random bytes minted by the Mac app that holds the Local API for the AI's profile, one key per
recipient, each with its own 8-byte `id`, a `level` and an optional expiry. It is never shown in a chat and
no call of the Local API returns it. Keys live in the Keychain (Keystore on Android), this device only.

**The tag** (`mesh-core-rs/src/command_auth.rs`; vector 51 pins it against an independent HMAC):

```text
tag  = HMAC-SHA256(key, "channel-cmd-v1" || counter || len(conversation) || conversation || text)[0..16]
         counter       u64, big endian (8 bytes)
         len(...)      one byte, the length of conversation (1 to 64)
         conversation  the RECIPIENT's 32-byte identity key
         text          the payload's logical body, exactly the bytes the receiver is shown
key 23 = counter (8 bytes, big endian) || tag (16 bytes)          -- 24 bytes
```

The recipient's identity key is the conversation, so a message copied into another AI's chat carries a tag that
is void there. The length byte stops the conversation and the text sliding into each other. The tag covers
the exact text, so nobody can change the command in flight (the envelope's AEAD already stops that; the tag
proves the SENDER holds the key). The comparison is constant-time. A tag over a text longer than the envelope
bound, or a conversation outside 1 to 64 bytes, is never made and never verifies.

**Key 23.** Written only by `Node` when the app hands it a command key and a counter, and only on `payloadType`
1 and 13 (`Payload::encode` refuses it elsewhere). A decoder reads it on any type and refuses it only when it is
not exactly 24 bytes. An older build skips key 23 as an unknown key (§1.2) and shows the same message. It
follows key 22 in canonical order. Being inside the AEAD proves the sender wrote it; it is a claim until the
receiver's `verify` agrees.

**Who verifies.** Only the Mac app that minted the keys, on the AI's own profile. It looks up the keys it
granted to the message's authenticated sender -- the contact whose key opened the envelope
(`opened_peer_identity`), never the payload's `senderIdentity` (key 1), which is only a claim (`J3748`) -- so
a tag is checked against that sender's keys and nobody else's. That app, not the core, keeps (a) the highest counter accepted per key, and
refuses a counter at or below it (replay, and a copy of an older message), and (b) the failure count per key:
five wrong tags from a sender within one hour pauses that key's command channel and tells the owner. A tag from a
sender who holds no key is ignored and counts against nobody. The result reaches the AI as the `authority`
field of the Local API message (the key's `level` id, or absent), set by CHANNEL and never from text.

**Levels.** A key's `level` is looked up in `scripts/ai-authority.json`, read by both platforms. Today the table
has one row, `owner`. Nothing branches on a count of levels or on "the" owner: a new level is a new row.

**Key grant** (`payloadType 60`, pairwise, not a group type, not a call). The Mac app sends it to the person or
profile the key is for, sealed like any message. Body, UTF-8 JSON, at most 512 bytes:

`{"v":1,"id":"<16 hex>","key":"<64 hex>","level":"owner","label":"<at most 64 bytes>","expires":<unix seconds, optional>}`

The receiver stores the key in its own Keychain or Keystore under the sender's identity, never shows it in
any conversation, and from then on attaches key 23 to every text it sends that sender. A grant is accepted only
from the identity that will receive the tags (the AI's profile): the key names the sender as its recipient by
construction, so a forged grant from anyone else only makes the receiver sign messages to the forger. A
malformed grant (wrong version, hex of the wrong length, a level or label out of bounds, a body over 512 bytes) is
dropped whole. A build that does not know type 60 drops it as an unknown type and never shows it. Revoking a key
on the minting Mac makes its tags fail from then on; the holder's copy is inert.

**Off by default.** Until the owner mints a first key, no tag is ever expected and `authority` is always absent.

### 5.41 Screen capture notice (`payloadType 29`, `J1399`)

One phone tells the other that **its own screen is being captured, or has stopped.** It is its own
payload type so that a capture notice is never a text message: it MUST NOT be stored or shown as
one, and a build that does not know type 29 MUST NOT deliver it as one.

**The body is exactly one byte:** `0x01` capture started, `0x00` capture stopped. Nothing else
travels: no time, no reason, no kind of capture. A receiver reads the state from a body of exactly
one byte whose value is `0` or `1`, and treats every other body (empty, longer, any other value)
as carrying no claim; it MUST NOT guess which state a different value meant.

**The name is capture and not recording**, because the only signal either platform offers cannot
tell a recording from a mirror or an AirPlay stream. A receiver words what it shows as a capture
event reported by the other phone, never as a statement that a recording is happening.

**What a sender may do.** It sends only into a one-to-one thread with a person it holds keys for.
It does not send into a note to oneself, an assistant thread, a group, or a thread with no keys.
The notice is sealed like any other payload (section 5.1), pairwise, with the usual delivery and
retry rules.

**What it proves.** The AEAD authenticates the reporting phone; it does not prove that a capture
happened. A receiver MUST NOT treat the notice as evidence of anything beyond "the other phone said
so", and MUST NOT attribute it to a thread other than the one it arrived in.

A platform that cannot detect capture sends nothing; silence is not a statement of safety.

### 5.42 Profile items (payload type 62, `J3417`)

One profile can live on several of a person's own devices. Type 62 is how one device tells another of the
same profile about items it holds: messages, receipts, reads, deletes, edits, reactions and routes. It is
pairwise between those devices only, never a group type and not a call. This section is the WIRE only; whether
an item may travel by a given route is `channel-algo`'s rule (`designs/J3417-profile-sync.md` section 2.3), which
derives the security class from each item's `route`; the core stores nothing. A build that does not know type 62
refuses it at decode and files nothing.

Body, in order:

`v`(1; = 1) | `count`(1; 1 to 200) | `frame` * `count`

`frame` = `frameLen`(u32 BE) | the item's bytes. Item, in order:

`kind`(1) | `thread` | `item`(16) | `fromLen`(1; 0 or 32) | `from` | `origin`(8) | `route`(1; 0 to 6) | `sync`(1; 0 or 1)
| `at`(u64 BE, seconds; at most 2^40) | `bodyLen`(u32 BE) | `itemBody`

`thread` = `0` (the profile's own, no further bytes) | `1` contact identity(32) | `2` group id(32) | `3` `len`(1; 1 to 254)
mail address (ASCII). **The thread numbers (own 0, contact 1, group 2, mail 3) are the same numbers the
`channel-algo` digests use.**

- **Never compressed.** A type 62 payload is always written with `compressed` = 0 and a decoder refuses any other
  value. The receiver bounds a decompressed body at 32,768 bytes, which a full batch exceeds, and one compressed
  stream must not mix threads.
- **Size.** The body is zero-padded to a bucket like every payload, and the largest bucket is 65,024 bytes. The
  maximum body is therefore **64,669 bytes**, which is 65,024 minus the payload's own CBOR in its worst case (64-bit
  sequence and time, 16 policy entries at full width); a test derives the figure and fails if it drifts. A single
  `itemBody` is at most 64,669 - 2 (header) - 4 (frame length) - 41 (the smallest item header) = **64,622** bytes,
  exactly what a one-item payload on the own thread with no `from` can carry. Items of a larger batch are sent in
  several payloads. A body over 32 KiB and one at the maximum are round-tripped through encode, seal, open and decode.
- **A message body's timer and view-once marks (`J3722`, additive).** The kind-1 body is JSON (`v` 1, `out`, `text`, `media`, `ms`, `ai`,
  `who`, `gname`, `em`). Two more optional keys: `exp` (the sender's absolute expiry for a disappearing message, epoch seconds, 1 to 2^40 - 1)
  and `vo` (1 a view-once message not yet spent on the sender, 2 spent). A body with `exp` of 0 or beyond the bound, or `vo` outside 1 to 2,
  is refused whole. A view-once body carries `text` "" and no `media`: it is the sender's STATE, never the words or the picture. The reader
  asks `channel-algo` `profile_sync` op 16: an item whose `exp` is not after now (or more than a year and a day ahead), or a view-once item
  that this profile did not send (`out` 0), is dropped unseen; otherwise it is filed with `expiresAt` = `exp`, and a view-once copy is filed
  opened only when `vo` is 2 (pending while 1); the contact's opened receipt reaches it as a state note, as it does the source (`view_once_receipt`). An older reader ignores both keys and files the message as an ordinary one (no timer): the cost of an additive key, accepted for builds before 3.089.
- **Kinds** (closed set): 1 message, 2 state, 3 read, 4 delete, 5 edit, 6 reaction, 7 route, 8 mail-route, and, from
  `J3701` stage 4, 9 roll, 10 card, 11 register, 12 request, 13 group. The
  item body is that kind's own bytes, parsed by the kind's own parser; the core does not look inside it. Kind 8
  (the email route bundle) is the profile's own register and is valid only on thread `0`.
  **Kinds 9 to 13 (the holdings of the share wire, 5.43).** 9 is one statement of the host roll and 11 one register of the
  profile (settings, door, handle ...): both valid only on thread `0`, and a register's `item` names which one and is never zero (the share wire keys every entry by a non-zero item). A roll statement's `item` is **the first 16 bytes of the statement's own 32-byte hash**, so a
  statement has one id on every host. 10 is one contact card and 12 one request (an invite slot or a contact request, open or
  settled): both valid only on a contact or mail thread, where the thread is the person. 13 is one informal group's roster: valid
  only on a group thread. Anything else, in any of the five, refuses the whole payload like any other known kind. The bodies are
  the apps' own, as for kind 1. An older reader stepped over these five as unknown kinds, so readers first still works.
- **An unknown kind is skipped whole.** Its frame is stepped over by `frameLen`, nothing in it is read, and it counts
  toward the 200 but yields no item. Kind 0 is unknown. A frame with no bytes at all is truncated, not skipped.
- **A known kind that is malformed in any way refuses the entire payload**: a thread tag other than 0 to 3, a
  `fromLen` other than 0 or 32, a `route` above 6, a `sync` other than 0 or 1, an `at` above 2^40, kind 8 on another
  thread, a mail address outside the subset below, a zero contact or group id, a zero `from`, a zero `item` id on any
  thread but `0` (the zero id is the register form), a byte left inside the frame, or an `itemBody` over the maximum.
- **Mail address.** The core accepts only the ASCII subset: 1 to 254 bytes of printable ASCII (0x21 to 0x7E), no
  upper-case letter, none of `<`, `>` or `"`, and exactly one `@` with text on both sides. Every non-ASCII
  character is therefore refused, including the bidirectional and format controls U+200E, U+200F, U+202A to U+202E and
  U+2066 to U+2069, and every internationalised label. Full normalisation (lower-casing, converting an IDN to
  punycode) is the **sender's** job; the core only checks what it is handed.
- **Bounds before allocation.** The whole body is at most the maximum; every `frameLen` and `bodyLen` is checked
  against the bytes actually present before a copy is made; a version other than 1, a count of 0 or over 200, a trailing
  byte and a truncation are refused whole.
- **`route`** is the route the item ORIGINALLY arrived by on the device that first filed it, never the route sync
  used: 0 unknown (a reader treats it as local), 1 B (Bluetooth), 2 W (Wi-Fi Direct), 3 L (LAN), 4 I (Internet), 5 SMS,
  6 direct IPv6. Any other value is refused. The security class is derived from it by `channel-algo`; a reader shows
  this route, never the sync route.
- **`sync` and re-sending.** Everything received in a type 62 payload is filed as a sync copy and is **never pushed
  onward**, whatever the `sync` byte says. The byte records provenance for display and diagnostics only and MUST NOT
  be consulted by any send decision: an implementation that re-pushed items whose byte was 0 would let one lying or
  buggy device start a storm. `origin` is the short id of the device that first filed the item; `from`, when
  present, is the original sender's identity for a received item.

**Bodies the apps write (`J3611`, no change to the wire above).** The core carries an item body without looking
inside; the apps write these: kind 6 (reaction) `{"v":1,"e":"<one emoji, empty = withdrawn>","c":<clock ms>}`, kind 5 (edit)
`{"v":1,"t":"<new text>","c":<clock ms>}`, both with `item` = the target message's id as the writing device names it (a
sent message's own id, a received one's sender id) and `from` = the sender's identity for a received message; and a
system line as kind 1 with a **version-3** body `{"v":3,"line":{"k":1..7,...}}` (a message reader requires version 1 and
skips it). A reaction or edit speaks for the profile's person only and is one register per message, ordered by `c`, then
by `origin` read as a big-endian number; the rules are `channel-algo` `chat_state_sync` ops 6 to 10.

**A contact's edit, relayed (`J3662`, no change to the wire above).** The device that holds a contact's link applies the contact's
edit of a message (5.30) and then tells the profile's other devices with the same kind 5 item, `item` = the sender's id for the
message and `from` = the author's identity (the thread's contact, or a group member's sender identity), with a register (`c`, then
`origin`) stamped on the receiving device. A reader applies it only to a received plain-text message by that author, in register
order (`channel-algo` `chat_state_sync` op 13 `sibling_contact_edit`); it never changes the person's own sent message, and an item
with no `from` is the person's own edit (op 7). The fifteen-minute window and the authorship check were the applying device's.

Vector 60 pins the layout and every refusal; code: `mesh-core-rs/src/profile_item.rs`.

### 5.43 The share wire (payload type 63, `J3701`)

Sharing a profile is a device catching up (`designs/J3701-share-spec.md` sections 2 and 5): each host of a profile tells each other host what it
holds and asks for what it lacks. Type 63 carries those words, pairwise between a profile's own devices, sealed under the device session. It is
never a group type and not a call; a build that does not know type 63 refuses it at decode and files nothing. **Readers first: no build sends it
yet** (stage 2 of the spec's build order), and there is no shadow, by the owner's ruling. The answers to a pull are NOT in this section: they are
ordinary profile items (5.42), sent after a pull head. Code: `mesh-core-rs/src/share_wire.rs`; vector 69 pins the layout, every refusal and the hash.

**Never compressed.** A type 63 payload is always written with `compressed` = 0 and a decoder refuses any other value: the length of a compressed
digest would say what it holds. Its body ceiling is type 62's, 64,669 bytes, the largest body that still pads into the top bucket; every body
below is far under it (the widest summary page is about 57 KiB, the widest digest page about 28 KiB).

Everything is big-endian. A body is `v`(1; = 1) | `op`(1) | fields, with no byte left over. The whole body is bounded before anything is read; an unknown
version or op is refused (a build that does not know an op cannot answer it either way), and any field outside its set, any list out of order and
any count that disagrees with its status refuses the WHOLE body. Nothing is skipped quietly. Every parser has a fuzz target (`share_wire`).

`thread` is 5.42's (own 0 | contact 1 + 32 | group 2 + 32 | mail 3 + len + address, same rules). `key` = `kind`(1; 1 to 255) | `item`(16, not all zero) |
`fromLen`(1; 0 or 32) | `from` (not all zero): the J3417 item key, with the kind in place of the family. `holding` is the number of a row in the
spec's section 3: roll 1, sessions 2, cards 3, registers 4, contact sessions 5, requests 6, groups 7, history 8, marks 9, media 10 (the same
numbers as `channel-algo` `share::H_*`; the generator fails if the two tables differ). A row of holding 8, 9 or 10 is one conversation's; every other
row is the profile's own thread. **Times** are whole milliseconds on the sender's clock, 0 to 2^40 x 1000 (the bound of a profile item's `at`).

| op | name | fields |
|---|---|---|
| 1 | summary | `gen`(u64) \| `page`(u16) \| `pages`(u16; 1 to 21; `page` < `pages`) \| `nPeers`(1; at most 9; 0 unless `page` is 0) \| `peers`(nPeers x 32, strictly ascending, not zero) \| `nRows`(u16; at most 200) \| rows |
| 2 | ask | `kind`(1), then by kind (below) |
| 3 | digest page | cursor block \| `n`(u16; at most 400) \| `n` x (`key` \| `at`(i64) \| `rank`(u64) \| `partsTotal`(u32)) |
| 4 | pull head | cursor block \| `items`(u16; at most 200) |
| 5 | media head | `thread` \| `key` \| `at`(i64) \| `part`(u32) \| `status`(1) \| `partsTotal`(u32) \| `chunks`(u16; at most 256) |
| 6 | reply | `kind`(1; 5 or 6) \| `status`(1) \| for kind 5 only: `target`(32, not zero) |

**Summary (op 1).** `gen` is the sender's counter of its own holdings: equal means unchanged, and a counter cannot be made to look newer by a wrong
clock (spec 15 item 8). `peers` names the hosts the sender holds sessions with, so an introduction is asked only of a host that can forward it
(spec 15 item 4). A row is `holding`(1; 1, 3, 4, 6, 7, 8, 9 or 10) \| `thread` \| `count`(u32) \| `newest`(i64) \| `hash`(16); rows are strictly
ascending by holding then the thread's bytes; `count` is 0 exactly when `newest` is 0, and then `hash` is all zero. A profile with more than 200 rows
sends several pages with one `gen` (at most 21, so 4,200 rows, over the kernel's 4,096): this is spec 15 item 17's windowing, and the kernel
treats an assembled summary of more than 4,096 rows as truncated. A summary is sent when a link opens and on the spec's 15-minute cadence; the
cadence and the page size are fixed so the traffic does not depend on what the profile holds.

**Ask (op 2).** `kind` 1 *summary*: nothing follows. 2 *digest* and 3 *pull*: `holding`(1) \| `thread` \| `before`(i64) \| `skip`(u32) \| `count`(u16)
\| `days`(1; 1, 7 or 30) \| `aflags`(1). A digest may be of holding 1, 3, 4, 6, 7, 8, 9 or 10 (count 1 to 400); a pull of holding 1, 3, 4, 6, 7, 8 or 9 (count
1 to 200). Holding 9 (the marks, `J3701` stage 5a) is a conversation's, like 8 and 10; an older build refuses an ask for it whole and answers nothing. `before` is exclusive and 0 means from the newest; `skip` is how many items at time `before` minus 1 were already given and is 0 when
`before` is 0 (the cursor of spec 15 item 1). `aflags`: bit 0 earlier pages of this walk were withheld by the route rule, bit 1 the asker's person
confirmed the PIN on the asker; any other bit is refused. **Both `days` and bit 1 are the asker's word** and so only as true as the asker's build (spec
15 item 15): the floor stops an honest build on a stolen device, not a modified one, unless the days are bound into the signed `add`, which is not
done here. 4 *media*: `thread`(a conversation) \| `key` \| `at`(i64) \| `part`(u32, below 2^20) \| `days` \| `aflags`(bit 1 only): one item at a time,
resumed from `part`. 5 *introduce*: `target`(32): "forward my introduction to this host". 6 *re-introduce*: `n`(1; 1 to 16) \| `n` x contact
identity(32, strictly ascending, not zero).

**The cursor block** of ops 3 and 4 is `holding` \| `thread` \| `before`(i64) \| `skip`(u32) \| `status`(1) \| `flags`(1) \| `nextBefore`(i64) \|
`nextSkip`(u32) \| `rowCount`(u32) \| `rowHash`(16). The first four echo the ask, so an answer is read without remembering it. `rowCount` and
`rowHash` are the row's current count and hash on the answerer, the newest word on that row (spec 15 item 4); the hash is zero exactly when the
count is 0. Digest answers are for holdings 1, 3, 4, 6, 7, 8, 9 and 10; pull heads for 1, 3, 4, 6, 7, 8 and 9.

**The answers of spec 15 item 3** are `status`: 0 *a page* (at least one entry, or one item following), 1 *nothing to add* (`flags` 0), 2 *some
withheld* (`flags` has bit 1, nothing carried), 3 *floored* (`flags` has bit 2, nothing carried), 4 *paused, the answerer is locked*, 5 *paused, the
answerer is in Secure Mode* (flags 0): a pause with its true reason, never a failure. `flags` also ride a page: bit 0 there is more below the
cursor, bit 1 the route rule cut something, bit 2 the walk hit the floor. A reader never takes *nothing carried* for *nothing to add*: only status 1
says that, and only a status 1 may be written as "Up to date" evidence. The next cursor never moves back up (`nextBefore` at most `before`, and when equal
`nextSkip` at least `skip`, whenever `before` is above 0) and a page must move it, so a hostile answer cannot loop the asker. A digest entry's `at`
never rises down the page, is below `before` when `before` is above 0, and no key appears twice. `partsTotal` is nonzero only on holding 10 and at most
2^20.

**The marks row (holding 9, `J3701` stage 5a).** The row holds a conversation's marks: the receipts, reads, edits, reactions and deletes of its messages. A digest entry's `key` has
`kind` 2 State, 3 Read, 4 Delete, 5 Edit or 6 Reaction (any other kind, or a `rank` of 0, refuses the whole page), `item` the target message's item and `from` as 5.42's
item key has it. `at` is the mark's own time on the marker's clock (when it was read, reacted, edited or deleted, or the state changed) and `partsTotal` is 0. `rank` is the
mark's own monotonic counter: State the delivery stage (2 sent, 3 delivered, 4 read), Read the time read in whole seconds, Edit the edit counter, Reaction the reaction
register's clock, Delete 1. The row hash is the usual preimage over (key, rank). A pull answer is ordinary type 62 items of those kinds. How a build folds an arriving mark
into the one it holds is `channel-algo` `share` op 5 (`merge`), not this wire. Vector 69 pins the new cases. A pull head's items follow as type 62 payloads, in any order and possibly before the head: the head says how many to expect and what the walk
found, and merging is idempotent.

**Media head (op 5).** Echoes the ask's thread, key, `at` and `part`. `status` as above; chunks follow in the existing attachment chunk payload exactly
when the status is 0, and then `partsTotal` is above 0, `part` below it and `part` + `chunks` at most `partsTotal`.

**Reply (op 6).** To an introduce or re-introduce ask: `status` 0 *forwarded*, 1 *this host holds no session with the target*, 4 or 5 *paused* as above.
A host that does not list the asker in its roll answers nothing at all.

**Bounds, in one place:** body 64,669 bytes; summary 200 rows per page, 21 pages, 9 peers; digest page 400 entries; pull head 200 items (type 62
carries at most 200 per payload, so a page is several); re-introduction 16 contacts; media head 256 chunks; 2^20 parts. Bytes in a pull answer per route (4 MiB
Wi-Fi Direct and LAN, 1 MiB IPv6, 128 KiB Bluetooth, 256 KiB relay) are the kernel's, not the wire's.

**The row hash** (spec 15 item 18). 16 bytes, which `channel-algo` treats as opaque. The first 16 bytes of SHA-256 over `"channel/share-row-v1\0"` \|
`holding`(1) \| `thread` \| `count`(u32) \| for each entry, in ascending order of its `key` bytes: `key` \| `rank`(u64). An empty row is 16 zero bytes.
The same SET of (key, rank) always gives the same hash whatever order it was filed in; holding and thread are in the preimage so a hash cannot be replayed
against another row; two entries with one key are refused (a row holds one rank per key). It is deliberately not an XOR or a sum of per-entry hashes,
which are cheap to update but let a contact, who chooses the ids in its own conversation, search for a set with a chosen hash. The cost of the sorted
form is a recompute when a row changes, which a platform does once per change.

**What is not here.** No key, sealing, AEAD, AAD or MAC is defined or changed; the bodies are plaintext for `seal`/`open`. The item kinds for the roll,
cards, registers, requests and groups are 9 to 13 in 5.42 (stage 4); this wire still names an item's `kind` as an opaque byte. Stage 4 adds the flat door
the apps call (`meshcore_share_wire_decode`, `meshcore_share_wire_encode`, `meshcore_share_row_hash`, and their JNI twins), which only carry bytes in a
fixed flat form and decide nothing.

## 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 |
| 16 | `PAIR_OFFER_V2` | initiator | first flight of a v2 pairing (§6.5.9) | 96 B | a hash of an X25519 public key, and a nonce |
| 17 | `PAIR_ACCEPT_V2` | responder | answering a `PAIR_OFFER_V2` | 160 B | an X25519 public key, a nonce, a signature it cannot attribute |
| 18 | `PAIR_CONFIRM_V2` | initiator | completing a v2 pairing | 128 B | the committed X25519 public key, a signature it cannot attribute |
| 19 | `PRESENCE_CHALLENGE` | both | on the peer's first PRESENCE, every encounter (§6.2a) | 64 B | a random nonce |
| 20 | `PRESENCE_PROOF` | both | answering the first challenge (§6.2a) | 640 B | the nonce, and 32 values it cannot tell from random |

**Types 11–13 are Handshake v1 (§6.5) and 16–18 Handshake v2 (§6.5.9), not part of the encounter
sequence** (14 and 15 are carried collection, §6.6.3, likewise outside it). 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.2a Presence challenge (`J1888` step 2)

**Why.** A `PRESENCE` token is a MAC only the pair can compute, but it is a *value*, not a proof: it
is sent in the clear and lives for an epoch, so anybody who heard it can repeat it. `J1762` stopped
binding a contact to the radio that showed one for exactly that reason. This exchange is the proof:
a fresh nonce, answered under the pair's `routingSecret`, so a repeated token proves nothing and a
repeated answer answers a nonce nobody is asking about.

```
PRESENCE_CHALLENGE = {1: nonce (16 B)}
PRESENCE_PROOF     = {1: nonce (16 B), 2: [mac_0 … mac_31]  (exactly PRESENCE_SET_SIZE × 16 B)}

mac_i = HMAC-SHA256(routingSecret_i,
          "mesh/presence-proof-v1" ‖ dir(P) ‖ epoch ‖ token_i ‖ nonce)[0..16]   for a real token_i
mac_i = 16 bytes from a CSPRNG                                                   for a padding entry
```

`token_i` is entry `i` of the **first** `PRESENCE` the prover `P` sent in this encounter, in the
order sent; `routingSecret_i` and `epoch` (uint64 big-endian) are those it was derived with (§4.4),
and `dir(P)` is the prover's own direction for that pair.

**Every encounter, both directions, whoever the peer is.** Each side sends exactly one
`PRESENCE_CHALLENGE`, with a nonce from a CSPRNG, as soon as it has received the peer's first
`PRESENCE` -- whether or not it recognised anything in it. Each side answers the first challenge it
receives, once it has sent a `PRESENCE`, with one `PRESENCE_PROOF` carrying a MAC for every entry of
that first list. A later challenge in the same encounter is ignored.

That shape is what keeps the exchange from saying anything:

- **The challenge names no token.** Naming the recognised one would tell any radio listener that the
  two phones are contacts. Sent to every peer, a challenge says nothing about recognition.
- **The proof covers all 32 entries, padding included.** Answering only real tokens would let a
  stranger tell real tokens from padding (and so count contacts) by which got an answer. A padding
  entry's MAC is random, indistinguishable from a real one to anyone without the pair's secret.

**What the verifier concludes.** `V` takes the index `i` of the token it matched in the peer's
first `PRESENCE` as contact `P` at epoch `e` (§4.4's ±1 window), recomputes `mac_i` with that
contact's `routingSecret`, `P`'s direction, `e` and its own nonce, and compares in constant time. A
match proves that a holder of the pair's secret answered **this encounter's** nonce. `V` may then
treat the link as reaching `P` for the rest of the link's life (never longer than the hint lifetime
of `J1888` step 1, never written to disk). A proof whose nonce is not the one `V` sent, or a second
proof, is ignored. Nothing in the encounter waits for a proof: §6.2's stages run as before, and on an
in-order link the proof arrives ahead of the peer's `WANT` because the challenge went out ahead of
the inventory.

**What it does not prove: proximity.** A relay (wormhole) holding a link to `V` and another to `P`
can pass the nonce to `P` and the proof back. Over a radio with no authenticated channel a MAC
cannot prevent that. It does raise the bar from today, where a token recorded earlier was enough:
the relay needs `P` live and in reach during the encounter. What it then gets is sealed envelopes it
cannot open and can only carry or drop. A send that uses this binding MUST keep its fallback (the
receipt window of `J1618`), so a dropping relay costs a delay, not a lost message.

**The Contacts-only gate (`J3744`).** *Contacts only* reach (`J1051`) used to admit a peer once its
`PRESENCE` matched a contact. A match is a value, not a proof, so a radio listener repeating a
recorded token was offered inventories and handed envelopes. The gate is therefore decided inside
the core's encounter, from one fact: **whether THIS encounter's own challenge was answered** (the
`verified_contact` of the verifier). A matched `PRESENCE` is not an input.

- **Not Contacts only:** the gate is always *allow*; nothing in this section applies.
- **Contacts only, allow** if and only if this encounter's proof verified. **Hold** until then:
  only `HELLO`, `CAPABILITY`, `PRESENCE`, `PRESENCE_CHALLENGE` and `PRESENCE_PROOF` are sent; no
  `INVENTORY` is offered (and none is recorded as offered); a peer `INVENTORY` is kept and acted on
  when the gate allows (`WANT` for what would be taken, our own `INVENTORY` offered); a peer `WANT`
  is not answered; an `ENVELOPE` is neither handed to the node nor `ACK`ed nor counted, and the host
  does not deliver it; the peer's token set plants no routing hint (§4.6) until the proof verifies.
- **Refuse**, which latches and finishes the encounter with no `GOODBYE`: a `PRESENCE_PROOF`
  carrying this encounter's nonce that does not verify (at once, never upgraded by a later proof),
  or no verified proof within `PRESENCE_PROOF_DEADLINE_MS` = **8 000 ms** of our challenge being
  **written to the link** (not queued, not from the peer's `PRESENCE`). A proof for another nonce is
  ignored: it neither refuses nor moves the deadline. The deadline is one constant, the same on every
  transport; the host reports the write time and asks the core, which decides.
- **No recognition oracle.** Under Contacts only the challenge goes to every peer and every
  challenge is answered, padding included, matched or not, so no frame type or timing before the
  proof tells a contact from a stranger. A stranger, a bad proof and a timeout share one ending: the
  host ends the link silently (no distinct reason on the wire) and remembers the peer as refused for
  one cooldown. The first frame that may differ is the `INVENTORY` after *allow*.
- **Per encounter, never kept.** The verified state is not cached across encounters or written to
  disk. A peer that never answers challenges (a build older than §6.2a) is refused after the
  deadline: Contacts only is a promise about who is handed mail, and an unanswered challenge proves nothing.

**Reflection and replay.** `dir(P)` and `token_i` are inside the MAC and pairwise tokens differ by
direction, so `V` cannot be made to answer for `P` by being sent `P`'s nonce. A recorded proof
answers only its own nonce, and `V` accepts only the nonce it sent in this encounter.

**Domain.** `"mesh/presence-proof-v1"` is distinct from every other label used under
`routingSecret` (§4.4, §6.3's route response), and every field after it is fixed-length.

**Older peers.** A build without this section receives a frame type it does not know, classifies it
as not a frame and drops it (as §6.6.3 records for types 14 and 15), and sends no proof. Nothing
waits for one, so it costs the asker 18 bytes.

### 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`, as the code sends them. **Bit 0 is `ROLE_HAS_INTERNET`** (`J1050` piece 1): this phone
can reach our mailbox right now -- advisory, and false is not a promise. Bit 3 is
`ROLE_CARRIES_COLLECTIONS` (§6.6.3). Bits 1 and 2 are reserved for **gateway-capable** and **wake
beacon** (§7.8) and nothing sets them. Remaining bits reserved; an unknown bit is ignored, never
refused.

*This paragraph said bit 0 marked a **persistent node** (`DECISIONS.md` A3) until 25 September 2026.
Nothing ever sent or read that meaning -- `roleFlags` went out as zero until `J1050` took bit 0 for
has-internet -- so the text described a design and the wire carried something else. Corrected to the
wire.*

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 queue longer than that (`MAX_QUEUED_ENVELOPES`, §6.5 rule 7) is offered a window of at most 128 entries, in fragment-key order, starting further along with each offer, so successive encounters reach every entry; the packet is unchanged on the wire. 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,
nor any it can already tell it would refuse (§7.7a, *WANT asks only for what would be taken*).

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

**`ACK`** — `{1: packetID, 2: accepted (bool)}`. A transport-level acknowledgement that a relay took
custody; `accepted` is true only when the receiver now has the envelope (§7.7a, the ACK table). 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` | 65536 |
| `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 where the code came from, and nothing else** (`J348`, 8 Sep 2026): a code read optically
off the other person's screen cannot be rewritten in transit, and a code sent remotely is protected
only when it is split across two channels (`PairingCode.split`/`join`, `DECISIONS.md` J15), so an
attacker must rewrite both. **A code sent whole in one message can be swapped undetected**; the app
says so where that choice is made. No pairing route compares digits: the confirmation code
(§6.5.8) is still derived and is displayed nowhere (`J760a`); a contact's page offers the safety number for
optional comparison only (`J2889`). The honest limit is still `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 the owner'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. `DECISIONS.md` E1 once closed it with the
ceremony -- both people compared the confirmation code, so a contact only one side believed in showed
at once -- and `J348` removed that comparison. Nothing in the ceremony closes it now. 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. **No app compares the confirmation
  code** (`J348`, 8 Sep 2026), so a pairing code substituted in a single channel is not detected in
  band. The defence is the code's provenance: an optical read in person, or a code split across two
  channels (§6.5.1). The code below is still derived by the core -- it is **v2** (`handshake.rs`
  `confirmation_code`), and this section described v1 until the external review of 30 Sep 2026 found
  the difference -- and is displayed nowhere (`J760a`). Pairing has no digit check by the owner's
  ruling of 7 Oct 2026. The derivation, for the record:

  ```
  code = SHA-256("channel/confirm-v2" ‖ sharedSecret ‖ idPubLow ‖ idPubHigh ‖ bucket_be64)[0..8]
         read as a big-endian u64, modulo 10^12        (twelve digits)
  bucket = unix_seconds / 30                            (a new code every 30 s)
  ```

  Over the handshake's shared secret as well as both identities, so a man-in-the-middle who replaced
  one side's pairing code would leave the two phones holding DIFFERENT codes -- which protects nobody
  while no screen shows them. **Its limit, stated:** nothing in the
  handshake commits a responder to its ephemeral before it sees the initiator's, so an attacker who
  has already substituted the pairing code and completed the other leg can search responder
  ephemerals for one whose code matches -- about 10^12 trials inside the 90 s window. That is the
  classic short-authentication-string weakness without a commitment step (ZRTP's fix is a hash
  commitment to the ephemeral in the first message, revealed later), and adding one is a staged
  handshake change: §6.5.9 (`J1776`).
- **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). CryptoSuite v2, hybrid X25519 + ML-KEM-768 (§4.9, `J1901`), is on the wire for conversations that begin through a published value or an in-person code when both phones carry it; contacts paired before that keep the classical agreement.
- **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.


#### 6.5.9 Handshake v2 — the initiator commits to its ephemeral first (`J1776`)

**Status: all three stages are built and on in the shipped apps (stage 2 on 8 October 2026, stage 3 on 10 October 2026, build 2.994).** Stage 1: the core answers v2 as a responder and can
start it as an initiator (`Handshake::initiate_v2`, `meshcore_handshake_begin_v2`, JNI
`handshakeBeginV2`), and both apps classify types 16–18 as frames. Stage 2: every pairing either app
starts as initiator begins in one function, `beginInitiator` on each platform, which sends v2 when
`sendsHandshakeV2` (iOS) / `SENDS_HANDSHAKE_V2` (Android) is on and never retries in v1; both flags
ship **on** (8 Oct 2026), so every app sends v2 (or v3) and none sends v1. Vectors: `vectors/11-handshake.json`. Stage 3 is one constant,
`handshake::ACCEPTS_HANDSHAKE_V1`, shipped **false** (10 Oct 2026, 2.994): at `false` a responder refuses a v1 offer before
any key is derived and the handshake is failed; see the staging at the end. It closes §6.5.8's stated limit: in v1 a man-in-the-middle who has already
substituted the pairing code can choose one of its ephemerals *after* seeing the honest party's, and
so search about 10^12 candidates for a pair of legs whose confirmation codes match. v2 adds the
hash commitment ZRTP uses (RFC 6189 §4.4.1.1): the initiator's first flight carries only a hash of its
ephemeral, which it reveals in the last. No new primitive; SHA-256, X25519 and Ed25519 as in v1.

**What this protects today, stated plainly.** The commitment's only purpose is to make two matching
confirmation codes mean no man-in-the-middle. Since `J348` no app shows or compares that code, so
neither v1's search nor v2's fix changes what a user can detect: with or without v2, a pairing code
substituted in a single channel completes undetected, and the split code (§6.5.1) is the only defence.
Pairing has no digit check, by the owner's ruling of 7 Oct 2026; v2 stays as built (and off) as
hardening of the exchange, not as a defence against substitution.

**Why this removes the search.** An attacker in the middle plays responder on one leg and initiator
on the other. As responder it sees only the honest initiator's *commitment* before it must send its
own ephemeral, so that leg's secret is fixed before the attacker learns what it will be. As initiator
it must commit before the honest responder answers, so that leg's secret is fixed by the responder's
fresh ephemeral. Neither leg leaves a free choice, so matching codes is one online guess per ceremony:
10^-12, and every failed guess would be visible to a comparison -- two different codes, or an attempt that fails.

**Frames** (§6's frame format, canonical CBOR, every field required, every width exact, §6.5.3's
decoder rules apply unchanged):

| # | Type | From | Body | Bound |
|--:|------|------|------|------:|
| 16 | `PAIR_OFFER_V2` | initiator | `{1: commitment (32 B), 2: nonce (16 B), 3: generation (uint32)}` | 96 B |
| 17 | `PAIR_ACCEPT_V2` | responder | `{1: ephemeralPublic (32 B), 2: nonce (16 B), 3: generation (uint32), 4: signature (64 B)}` | 160 B |
| 18 | `PAIR_CONFIRM_V2` | initiator | `{1: ephemeralPublic (32 B), 2: signature (64 B)}` | 128 B |

```
commitment = SHA-256("mesh/handshake-v2/commit" ‖ initiatorEphPub)            32 bytes
```

**The transcript is by ROLE, not by identity ordering**, because v2's flights are not symmetric and
both parties always know which role they hold:

```
transcript_v2(signerRole) =
    "mesh/handshake-v2"      17 bytes
  ‖ initiatorIdPub           32 bytes
  ‖ responderIdPub           32 bytes
  ‖ commitment               32 bytes   from PAIR_OFFER_V2
  ‖ responderEphPub          32 bytes
  ‖ initiatorNonce           16 bytes
  ‖ responderNonce           16 bytes
  ‖ generation                4 bytes   uint32 big-endian, the agreed value
  ‖ signerRole                1 byte    0x00 initiator, 0x01 responder
                            ---------
                            182 bytes

initiatorSigned_v2 = transcript_v2(0x00) ‖ initiatorEphPub               214 bytes
```

The responder signs `transcript_v2(0x01)`, before it can know the initiator's ephemeral, which is the
point; that ephemeral reaches the responder's signature only through `commitment` (SHA-256 second-
preimage resistance). The initiator signs `initiatorSigned_v2`, which also names the ephemeral it
reveals, so the binding does not rest on check 10 alone.

**`signerRole` means ROLE here, not identity order** as in v1, so the two versions MUST NOT share the
code that assigns it: the same party's byte is opposite whenever the initiator is `idPubHigh`.

**Exchange.**

1. Initiator: fresh ephemeral and nonce; sends `PAIR_OFFER_V2 {commitment, nonce, generation}`.
2. Responder (its ephemeral and nonce drawn when it began waiting, as in v1): checks 2, 3, 5, 9
   (§6.5.6); `generation = max(offered, own)`; signs `transcript_v2(0x01)`; sends `PAIR_ACCEPT_V2`. **It performs no agreement yet** -- it has no
   initiator ephemeral -- so a forged offer costs it one signature and nothing else.
3. Initiator: checks 2, 3, 4 (its own ephemeral vs the responder's), 5, 6, 9, then verifies the
   responder's signature (7); agreement and check 8; signs `initiatorSigned_v2`; sends
   `PAIR_CONFIRM_V2 {ephemeralPublic, signature}`; complete.
4. Responder: checks 2, 3; **check 10: `SHA-256("mesh/handshake-v2/commit" ‖ ephemeralPublic)` equals
   the commitment it received**; check 4; verifies the initiator's signature over `initiatorSigned_v2`
   (7); agreement and 8; complete.

Check 10 is new and abort-class like the rest. **A revealed ephemeral is spent:** an initiator MUST
NOT reuse one in any later attempt, because a reused ephemeral is public before the next offer and
hands a responder-side attacker v1's free choice again. Types 16–18 are ignored inside an encounter
exactly as 11–13 are (§6.5.3). Outputs are §6.5.7's -- **identity-ordered** (`nonceLow ‖ nonceHigh`),
not the transcript's role order -- computed from the same `sharedSecret`, nonces and generation, so everything downstream -- `pairingSalt`, the contact
derivation, the confirmation code (`confirm-v2`) -- is unchanged and cannot tell which version paired.

**Staging -- readers first, as every wire change here.**

- **Stage 1:** every responder answers `PAIR_OFFER_V2` as above, and still answers v1. No initiator
  sends v2. Nothing any user sees changes. Every app-side filter that recognises pairing frames by
  type (11–13) MUST learn 16–18 in the same release -- the `J1154` lesson: a validator on the reader
  side that does not know the new shape discards it silently.
- **Stage 2 -- where the protection arrives:** once stage 1 is installed on every build in use,
  initiators send v2 **and never fall back to v1**, on timeout or otherwise. *Built, off:* the
  version is chosen in one place per platform (`beginInitiator`), as a function of the flag alone,
  and `preflight.py`'s `v2 initiator only` fails on any other v1 initiator, any catch inside that
  function, any override of the flag, or flags that disagree. The grinding attacker
  must play RESPONDER on some leg (as an initiator it fixes its ephemeral before the honest responder
  answers, in v1 as in v2), and a v2-only initiator denies it the one flight it grinds against. An
  initiator that retried in v1 when a peer ignored type 16 would hand that flight back, so the
  fallback is forbidden rather than merely unimplemented.
- **Stage 3:** responders refuse v1 offers. Housekeeping, not protection: a v1 offer from an attacker
  playing initiator gives it no choice to grind.

Stage 1 on its own changes nothing an attacker faces; the limit in §6.5.8 stands until stage 2 is the
only initiator in use, and it is recorded here so that "v2 is built" is never read as "v1's limit is
gone".

> **The v2-only cut-over is complete (`J1833`, outside review I7).** The two switches below were
> turned on in order, each once a measurement said it was safe:
>
> 1. **Stage 2 on** (`sendsHandshakeV2` / `SENDS_HANDSHAKE_V2`, 8 October 2026), after the oldest build
>    installed on any tester's phone, on both platforms, answered type 16. An honest initiator never
>    offers v1, which is where the protection arrives.
> 2. **Stage 3** -- the core refuses types 11-13 as a responder, one constant in `handshake.rs`
>    (`ACCEPTS_HANDSHAKE_V1`, `false` since 10 October 2026, build 2.994). A phone still on stage 1
>    can no longer start a pairing with an up-to-date one.
> 3. The v1 frames, transcript and tests remain in the core; the outputs (§6.5.7) and the confirmation
>    code are shared with v2.


### 6.6 Carried collection — the return path through a carrier (`J1050`)

**Status: BUILT, 25 September 2026, on both apps (`CarriedCollection.swift`, `CarriedCollection.kt`) and the relay (`carried.mts`), gated on both phones by Relay for Others. Not yet measured between two handsets (`J1050`). Checked 30 September 2026 (`J1800`).**

A phone with no route to our mailbox (plane Wi-Fi bought on one family phone, a phone with no data
abroad) can already SEND through a nearby phone that announced `ROLE_HAS_INTERNET` in `CAPABILITY`
(§6.3): the carrier takes custody of a sealed envelope and deposits it. Nothing brings the reply
back. The mailbox is collected by presenting a collection token, and a carrier holds none of the
attached phone's tokens.

**Decided by the owner, 25 September 2026:** *"pass along the encrypted request seems more secure."* The
carrier forwards the attached phone's OWN collection request, sealed so that the carrier cannot read
it, rather than being handed that phone's tokens. Collection tokens are pairwise, so a token set
handed to a stranger discloses the phone's number of correspondents as well as the values -- the
cardinality leak §4.8 and `PRESENCE`'s fixed size (§6.3) exist to prevent.

#### 6.6.1 The construction is Oblivious HTTP, not a new one

The shape -- a client seals a request to a gateway's public key, an untrusted party forwards it, and
the gateway's answer comes back sealed to a key only that client can derive -- is **Oblivious HTTP,
RFC 9458**. This section adopts its encapsulation byte for byte (RFC 9458 §4.3 request, §4.4
response) and changes exactly one thing: the media-type label bound into the HPKE `info` and the
response export, so that a carried collection can never be confused with, or replayed as, any other
use of the same key. No step is ours.

- **HPKE (RFC 9180), base mode**, single-shot. Suite: KEM `0x0020` DHKEM(X25519, HKDF-SHA256), KDF
  `0x0001` HKDF-SHA256, AEAD `0x0003` ChaCha20-Poly1305 -- the three primitives the core already
  holds (§4, §5.6). The implementation MUST reproduce RFC 9180 Appendix A.2.1 before it is trusted
  with anything else.
- **Key configuration** (RFC 9458 §3): `keyId (1 B) ‖ kemId (2 B) ‖ kdfId (2 B) ‖ aeadId (2 B)`, the
  7-byte `hdr`.
- **Request**: `info = "message/channel-collect request" ‖ 0x00 ‖ hdr`;
  `(enc, ctx) = SetupBaseS(pkR, info)`; `ct = ctx.Seal("", plaintext)`;
  `encapsulatedRequest = hdr ‖ enc (32 B) ‖ ct`.
- **Response**: `secret = ctx.Export("message/channel-collect response", 32)`;
  `responseNonce` = 32 random bytes; `prk = Extract(enc ‖ responseNonce, secret)`;
  `key = Expand(prk, "key", 32)`; `nonce = Expand(prk, "nonce", 12)`;
  `encapsulatedResponse = responseNonce ‖ Seal(key, nonce, "", plaintext)`.

The relay's key configuration is **compiled into the app**, never fetched. A phone using this path
has no route of its own, so anything it fetched would arrive through the very party this construction
is meant to blind. `keyId` allows a rotation: a new key ships in a build **before** the relay uses it,
and the relay honours at most two ids at once -- the readers-first order `CLAUDE.md` records for every
wire change.

#### 6.6.2 What is sealed

Canonical CBOR (§1) throughout. Both plaintexts are **padded to a fixed size before sealing**, because
the lengths are the one thing the carrier sees.

**One collection token per request, and a fixed number of requests.** The relay's `confirm` refuses
to batch tokens for a reason that applies here unchanged (`J612`, `J68c`): several tokens in one
request tell the relay they belong to one person, which is the correspondence graph. So a carried
request names exactly one token, as an ordinary `collect` does. And so that the carrier cannot count
correspondents by counting requests, the attached phone sends exactly `CARRY_REQUESTS_PER_CYCLE`
**[policy]** (default 8) requests per cycle: its real tokens first, rotating across cycles as
`PRESENCE` samples (§6.3), and the rest **dummy tokens** -- fresh random values of the same form, which
name an empty mailbox the relay cannot tell from any other empty one.

**Request plaintext** -- `{1: version (= 1), 2: collectToken, 3: [envelopeId …], 4: issuedAt (uint),
5: padding (bstr)}`

- `2`: one collection token (§4; 32 lowercase hex characters, the relay's `validToken`).
- `3`: envelope ids **under that same token** that an earlier carried response delivered and the
  phone has filed -- the relay's `confirm`, which deletes `token/id`, carried inside the seal so the
  carrier learns nothing from it. At most `CARRY_MAX_CONFIRMS` **[policy]** (default 20) -- under
  the relay's own cap (`J1159`, 64) because twenty of the longest ids are what fit a fixed 1024-byte
  plaintext; the rest wait for the next request under that token. A dummy request carries none.
- `4`: the phone's clock, in Unix seconds. **The anti-replay rule RFC 9458 §6.5.1 recommends**: the
  relay refuses a request more than `CARRY_FRESHNESS` **[policy]** (default 300 s) from its own clock
  in either direction. Inside that window a replay repeats an idempotent collect and an idempotent
  confirm, sealed to a context only the phone holds, so it teaches the replayer nothing the original
  answer did not.
- `5`: zero bytes, sized so the encoded plaintext is exactly `CARRY_REQUEST_PLAINTEXT` **[policy]**
  (default 1024 B).

**Response plaintext** -- `{1: version (= 1), 2: [[envelopeId, envelope (bstr), ring (bool)] …],
3: more (bool), 4: padding (bstr)}`

- `2`: collected envelopes, each exactly as the relay's `collect` returns it for that token, the
  `ring` flag included. They are already sealed end to end (§2, §5).
- `3`: `true` when the relay held more than fitted; the phone asks again next cycle.
- `4`: pads the encoded plaintext up to the smallest of `CARRY_RESPONSE_BUCKETS` **[policy]**
  (default 4 KB, 12 KB, 28 KB) that holds it. The relay stops adding envelopes before the largest
  bucket would overflow and sets `more`. 28 KB keeps the whole response inside one `ENVELOPE`-sized
  radio frame (§6.1).

**On the internet hop the relay pads again, outside the seal.** The HTTP body is
`length (u32, big-endian) ‖ encapsulatedResponse ‖ zero bytes` to exactly `CARRY_HTTP_RESPONSE`
**[policy]** (the largest bucket plus overhead), and the carrier strips it before the radio. The
carrier learns the bucket regardless -- it is handling the bytes -- but the network the carrier is on
sees one size for every answer. `collect` already pads its answers the same way (`_pad`).

#### 6.6.3 On the radio

Two packet types join §6.1. Like types 11-13 they share the frame space and are **not** part of the
§6.2 sequence; either may be sent at any point after `CAPABILITY` in an encounter.

**Only to a peer that said it will answer.** A carrier sets `roleFlags` bit 3,
`ROLE_CARRIES_COLLECTIONS`, while Relay for Others is on and it can reach the mailbox; a phone sends
`CARRY_REQUEST` only to a peer whose `CAPABILITY` carried it. A build without types 14 and 15 never
sets the bit, so nobody asks it -- and if one were asked anyway, it classifies an unknown type as not
a frame and drops it, which costs the asker one request and the old build nothing.

| # | Type | Direction | When sent | Bound | What the carrier learns |
|--:|------|-----------|-----------|------:|---------------------|
| 14 | `CARRY_REQUEST` | attached → carrier | the attached phone has no route and the peer set `ROLE_HAS_INTERNET` | 2.2 KB | that this peer wants its mail fetched, and when |
| 15 | `CARRY_RESPONSE` | carrier → attached | the relay answered, or the carrier declines | 29 KB | one of three sizes |

**`CARRY_REQUEST`** -- `{1: requestId (8 B, random), 2: encapsulatedRequest (bstr)}`.

**`CARRY_RESPONSE`** -- `{1: requestId, 2: status (uint), 3: encapsulatedResponse (bstr, only when
status = 1)}`. Status: 1 answered · 2 declined (carrying is off, or a cap was reached) · 3 the relay
could not be reached · 4 the relay refused the request.

`requestId` is chosen by the attached phone to match an answer to its question. It is fresh random
per request and MUST NOT be derived from anything else.

#### 6.6.4 The carrier

- Offers this only while **Relay for Others** is on -- the reciprocity rule of `J1046`: a phone that
  carries nobody is carried by nobody -- and only while it can reach our mailbox itself.
- Forwards `encapsulatedRequest` **unchanged** as the body of `POST /carried`
  (`application/octet-stream`) and returns the body of the answer unchanged. It MUST NOT parse,
  store or log either; the only state it keeps is `requestId → peer` until the answer is handed back
  or `CARRY_REQUEST_TTL` **[policy]** (default 60 s) passes.
- Enforces, per attached peer: one request in flight, at least `CARRY_MIN_INTERVAL` **[policy]**
  (default 15 s) between requests, and `CARRY_MAX_PER_HOUR` **[policy]** (default 120). A request
  over a cap is answered status 2, at once, so the asker is not left waiting.
- Rejects an `encapsulatedRequest` whose length is not exactly `7 + 32 + CARRY_REQUEST_PLAINTEXT + 16`
  before anything else -- the one check a party that cannot read the request can still make.
- The in-flight and interval caps count a whole **cycle** (`CARRY_REQUESTS_PER_CYCLE` requests) as one
  unit, so the caps cannot be used to count the real ones.

#### 6.6.5 The relay

`POST /carried`, stateless:

1. Refuse any body whose length is not the fixed request length, or whose `hdr` names a key id it does
   not hold or a suite other than §6.6.1's. **One undifferentiated refusal** for every failure from
   here down, so that the endpoint is not an oracle for which check failed.
2. Decapsulate. Decode the plaintext with the core's strict decoder: canonical CBOR, exactly the five
   keys, every bound in §6.6.2 checked -- the token, every id, the freshness window -- **before any
   delete**, so a refused request never half-applies.
3. Confirm the listed ids under the token, then collect for it -- the same code paths, validation and
   storage as `confirm` and `collect`, with the byte budget set by the largest bucket.
4. Seal the response to the request's context, pad the HTTP body (§6.6.2), and return it.
5. Every refusal is the same status, the same body and the same length.

**What the relay learns** is what an ordinary `collect` teaches it -- one token and its ids per
request -- plus that the request came through somebody else. The address it sees is the **carrier's**,
not the attached phone's. That address groups the attached phone's requests in time with the
carrier's own and with any other phone the carrier is serving, exactly as one shared network address
(a plane's, a café's) already groups everybody behind it; this path adds no grouping a NAT does not. The relay's private key opens carried requests and nothing
else; it is held as a secret environment variable and is listed in `LEGAL-RISK.md`.

#### 6.6.6 The attached phone

- Sends `CARRY_REQUEST` only when it has no route of its own; the moment it has one, it stops and
  collects directly.
- Keeps each request's `enc` and sending context (RFC 9458 §4.4 needs both) until the answer
  **opens**, or `CARRY_REQUEST_TTL` passes, then erases them. `status` is outside the seal and only a
  hint: a carrier can send status 1 with garbage, or drop a real answer. **A response that fails to
  open is not an empty mailbox**, is never followed by a confirm, and does not end the wait early.
- Hands every collected envelope to the ordinary inbound path -- trial decryption, the duplicate
  guard, filing -- exactly as if it had come from `collect`. A carried envelope is not trusted more or
  less than any other; it is already sealed end to end.
- Confirms what it filed in its **next** carried request (`3`), never through the carrier in clear.

#### 6.6.7 What each party can see, stated plainly

| | learns | cannot learn |
|---|---|---|
| **carrier** | that a nearby phone fetched its mail through it, when, and for each of the eight requests which of three sizes came back -- so whether mail was waiting and roughly how much, and how long the relay took, which says the same | which requests were real, the tokens, how many correspondents, who wrote, any envelope, any content |
| **anyone listening to the radio** | the same as the carrier: carried frames between phones that have not paired are not encrypted on the link, only inside the seal | the same as the carrier |
| **relay** | one token and its ids per request, as for any collect, and that the request was carried | anything inside an envelope; the attached phone's address |
| **the network the carrier is on** | that the carrier talked to our relay, and how often | anything about the attached phone, including how much mail it had |

The one sentence each side is owed (the rule in `CLAUDE.md`, *every feature whose use creates a risk
says so*): the carrier's is that **Relay for Others fetches mail for nearby phones with no internet,
using this phone's data and battery, and none of it is readable here**; the attached phone's is that
**fetching through a nearby phone tells its owner when you checked for mail and roughly how much was
waiting, never who sent it or what it says.**

#### 6.6.8 Vectors

`vectors/09-carried-collection.json` is **generated**, never transcribed, from fixed keys, a fixed
ephemeral key and a fixed response nonce: `hdr`, `enc`, both encapsulations, both plaintexts padded,
and the exported secret -- so the relay's decapsulation and both apps' encapsulation are checked
against the same bytes. Two published vectors are reproduced FIRST, because a vector we generated can
only prove we agree with ourselves: RFC 9180 Appendix A.2.1 for HPKE, and RFC 9458 Appendix A for the
encapsulation (with its own labels, before ours are substituted). The response step uses the KDF's
plain `Extract`/`Expand`, not HPKE's labeled ones, and seals with the derived key directly, never
with the request context's `Seal` -- both are the easy mistake, and Appendix A catches both.

**The relay decapsulates with the same core**, built for WebAssembly (`J574`), so the construction has
one implementation, as §0 requires of every other part of this protocol.

*Reviewed externally on 25 September 2026 against this section, §6.1-6.3, `collect.mts` and
`confirm.mts`. Five findings stood and are folded in above: batched tokens are the `J68c` graph;
confirms need their token; replay needs a freshness bound; the phone must keep `enc` and fail closed;
the internet hop leaked the bucket. One did not: it doubted that `info` carries `0x00 ‖ hdr`, and RFC
9458 §4.3 says it does.*

---

### 6.7 The prekey invitation, and v4: an invitation that proves who sent it (`J1678`)

**Status, 27 September 2026: the core is built and tested (`prekey.rs`, `pairing_code.rs`), and both
apps read v4 (stage 1, below). Stage 2 (both apps send it) waits for stage 1 to be installed.**

**Status, 30 September 2026: stage 2 (both apps send v4) is built behind `sendsProvenInvitations` /
`SENDS_PROVEN_INVITATIONS`, off. Stage 3 (automatic link repair, rule 7) is built behind two more
switches, both off: `acceptsProvenRepairs` / `ACCEPTS_PROVEN_REPAIRS` and `repairsBrokenLinks` /
`REPAIRS_BROKEN_LINKS` (§6.7.6).**

The C surface: `meshcore_prekey_derive_sender_v2`, `meshcore_pairing_encode_v4`, and on the
recipient's side **one call, `meshcore_prekey_ring_pairing_candidates`** (`J3752`: the earlier bare-secret `meshcore_pairing_recipient_derive` was removed, ABI 5), which takes the invitation's bytes
and picks the agreement itself (`meshcore_pairing_sender_prekey` is informational). JNI has the same.

This section also records the invitation as it has shipped since `J276`, which was specified only in
source comments until now.

#### 6.7.1 What ships today: v3 and the v1 agreement

A person reached at a handle, number or address the sender only knows the value of is sent an
**invitation**: a pairing code (`pairing_code.rs`) deposited in their slot (`v1/invite`), carrying
a sealed first message.

```
bundle (published by the recipient, verified on fetch)
    [0x01][32 Ed25519 identity][32 X25519 prekey][64 Ed25519 signature over the prekey]   = 129 bytes

invitation v3
    [0x03][32 sender identity][8 nonce][1 nameLen][name][32 ephemeral][2 msgLen BE][sealed]

v1 agreement   raw = X25519(ephemeral, recipientPrekey)
               sharedSecret = SHA-256("channel/prekey-v1/secret" || raw || ephemeral || recipientPrekey)
               pairingNonce = SHA-256("channel/prekey-v1/nonce"  || raw || ephemeral || recipientPrekey)
```

Both sides then run §4.2 with the sender identity **the invitation names**, and the first message is
sealed under the result.

**The defect.** Nothing in v1 depends on a secret the sender holds for longer than the message. So
anybody who knows a person's public identity key, which is on every card they have given out, can
write an invitation that names it, and the recipient derives exactly the keys the forger used. That
is why every invitation waits to be accepted by hand, and why an invitation from somebody the
recipient **already knows** cannot be taken on trust: accepting it automatically would hand the
conversation to whoever wrote it. It is the gate on repairing a broken link automatically.

#### 6.7.2 v4 and the v2 agreement

The sender adds its **own** prekey bundle, and the agreement adds a second Diffie-Hellman using that
prekey's private half: X3DH's `DH1 = DH(IK_A, SPK_B)` (Signal, *The X3DH Key Agreement Protocol*,
revision 1), with the sender's signed prekey standing where X3DH's identity key stands. CHANNEL's
identity keys are Ed25519 and are never used for X25519: one key, one role.

```
invitation v4
    v3's layout with version 0x04, then [129 sender bundle]      (the sender's own, as published)

v2 agreement   dhStatic    = X25519(senderPrekeySecret, recipientPrekey)
               dhEphemeral = X25519(ephemeralSecret,    recipientPrekey)
               okm(64)     = HKDF-SHA256(salt = none,
                                         ikm  = dhStatic || dhEphemeral,
                                         info = "channel/prekey-v2" || ephemeral || recipientPrekey
                                                || senderPrekey || senderIdentity || recipientIdentity)
               sharedSecret = okm[0..32]    pairingNonce = okm[32..64]
```

The first message is sealed under §4.2 run over the v2 result. Only the holder of the sender prekey's
private half can compute `dhStatic`, and that prekey is signed by the identity the invitation names.
So **a v4 invitation whose first message opens was written by a device holding a prekey that identity
signed.** That is the same key store as the identity itself on every CHANNEL device, but it is a
weaker statement than "holds the identity key", and rule 7 is written for the weaker one.

Known answer, computed independently (OpenSSL through Python's `cryptography`, and HKDF from RFC
5869), pinned in `prekey.rs::v2_known_answer`: identity seeds `01…` and `02…`, prekey secrets
`0b…` and `16…`, ephemeral secret `07…` give sharedSecret
`314d3ccff87c2c9e018ab5e4c9773dfc07f10f411c3da568f74730438778d474`.

#### 6.7.3 Rules — normative

**Decoding** (`pairing_code::decode`, the one implementation):

1. A v4 code's trailing bundle is verified (`verify_strict`) and must name the header's identity. Only
   then does the code carry `senderPrekey`.
2. A v4 whose bundle fails keeps its identity, nonce and name, and **loses its ephemeral key and
   message**. It is still somebody asking to be accepted; it is not a message anybody can open.

**The recipient:**

3. **The core picks the agreement, from the invitation's bytes** (`meshcore_prekey_ring_pairing_candidates`, one candidate per ring record):
   v2 for a v4 whose bundle verified, v1 for a v3. **Never v1 on a v4**: a forger who seals under v1
   and attaches a real, published bundle has built exactly a message v1 would open. One call, rather
   than a separate v2 function fed fields, so no caller can pair one invitation's identity with
   another's prekey (external review, 27 Sep 2026).
4. A v4 whose message opens is **proven**. `senderPrekey`, and the core's "v2 was used", are a
   selector and never a proof: anybody can attach a published bundle.
5. A v4 whose message does not open is treated as a v3 that carried no message: an invitation,
   waiting to be accepted by hand, and not proven.
6. A v1 to v3 invitation is unproven, as today.
7. **Only a proven invitation may be accepted without the person being asked** (step 3, automatic
   link repair), and only when all of these hold:
   - its identity is, **byte for byte**, a contact's key. Never a match by name or handle; the name
     travels unsigned.
   - its sender prekey is one this phone already has reason to believe is that contact's: the
     bundle it holds for them, or the one currently published. A prekey the contact signed long ago
     and has since replaced proves possession of an old key, not of the device.
   - it has not been seen before: the same invitation derives the same keys, so a replayed one
     would put a conversation back on an old session. Deduplicate on the ephemeral key.
   - the link it replaces is one this phone judges broken **on its own evidence**. A healthy
     conversation is never re-keyed by an invitation, which also bounds the key-compromise case
     below. (`J2525`: that evidence may be a check of the old link this phone makes after the
     invitation arrives and that goes unanswered; the invitation is held, not taken, meanwhile --
     §6.7.6.)

**The sender:** builds v4 only with its own prekey secret and the bundle it publishes for that secret,
and refuses (the core refuses) an invitation to its own identity.

#### 6.7.4 What v2 does and does not add

- **Adds:** proof of the sender, to the recipient only. There is no signature over the invitation, so
  the recipient holds nothing a third party would accept as proof that the sender wrote to them: the
  same deniability X3DH keeps.
- **Unchanged:** our server still hands out the recipient's bundle and could substitute its own for a
  stranger's, as before (`PairingProvenance.prekeyAgreement`). Nothing on screen flags that, and no route
  requires a comparison (`J346`, `J348`, `J903`); a contact's page offers the safety number for optional comparison
  in person or on a call (`J2889`), and a video call with the person is the check.
- **Not added:** protection against a thief of the **recipient's** prekey, who can compute `dhStatic`
  from the other side and impersonate anybody to that recipient (key-compromise impersonation, as in
  X3DH). A phone whose keys have been taken needs new keys, not a better invitation. Rule 7's last
  condition keeps that from turning into a silent takeover of a working conversation.
- Rotating a prekey has no effect on a pairing already made: the invitation's keys are used once, to
  run §4.2.
- 129 bytes longer, far inside the relay's envelope ceiling. The relay never parses an invitation.

> **A labelled prekey signature (`J1833`, outside review I1) -- BUILT as bundle version 2.** A v1
> bundle's signature is the identity key's Ed25519 signature over the bare 32-byte prekey, and
> `meshcore_prekey_sign` signs any 32 bytes it is handed; that call remains in the core for v1. A v2
> bundle signs a labelled statement, `"channel/prekey-bundle-v2\0" ‖ prekey ‖ ML-KEM key`, verified
> strictly with FIPS 203's key check (`prekey.rs`, `bundle_v2_statement`), and the signing call
> (`meshcore_prekey_sign_bundle_v2`, core `sign_bundle_v2`) takes the SECRETS -- the identity seed and the
> prekey secret -- and derives both public halves itself, so it cannot be used as a general signing
> oracle (a ring record with its own ML-KEM seed signs through `sign_bundle_v2_with`, `J2895`). Every other statement the identity key signs is
> labelled and longer than 32 bytes (`channel/ratchet-start-v2`, the MLS labels), so no signature
> stands for another.

> **Prekey rotation (`J2895`) -- the prekey ring, in the core.** The X25519 prekey is no longer one
> fixed key. `prekey_ring.rs` keeps a **ring**: the current record and at most seven it superseded
> (`RING_MAX` 8), each an X25519 secret with its own ML-KEM half (§4.9.2), stored by the app as opaque
> bytes (at most 906) in the secret store beside the old `.prekey`, never in a backup.
>
> - **Rotation:** a new current record from the caller's 32 + 64 random bytes; the old current is
>   marked superseded; a full ring drops its oldest record, wiped. Due when the current record is
>   LEGACY or `ROTATE_EVERY_MS` (7 days) old. Whether a build rotates is the apps' switch,
>   `ROTATES_PREKEYS`, which is ON in 3.013.
> - **Pruning:** a record superseded more than `KEEP_SUPERSEDED_MS` (30 days) ago is deleted and its
>   bytes wiped, and **a superseded LEGACY record (its ML-KEM key derived from its X25519 secret) is
>   deleted at once** (`J3752`) -- only when the app passes its `PRUNES_SUPERSEDED_PREKEYS` switch,
>   which is ON since 1 Nov 2026 (it was OFF from 3.013, J2895c, because deleting a secret cannot be
>   undone). The current record is never pruned, LEGACY or not. Apps prune right after a rotation and
>   on every pass that finds nothing due.
> - **Opening what was sealed to an old record:** every recipient-side derive (an invitation v3, v4 or
>   v5; a device hello; a group introduction) takes the ring and returns one **candidate** per record,
>   newest first. Each is a selector, not a proof (rule 4): the first whose sealed first message opens
>   is the answer. No wire change: an invitation never named the recipient's prekey.
> - **Proving with an old record:** the sender derives (v2, v3, a group introduction) name the
>   record they prove with, so a renewal can prove with the prekey a contact is known to hold.
> - So a recorded first message stays openable by a later thief of the device for at most
>   `ROTATE_EVERY_MS + KEEP_SUPERSEDED_MS` = 37 days after it was sealed (33.5 days on average) once both switches are on; with pruning OFF a record stays until the ring of 8 pushes it out.
>
> **No one-time prekeys** (`DECISIONS.md` `J2895`). They help only the relay-fetched path (renewals,
> introductions and device hellos use cached bundles), they need a per-slot pool at the relay that
> counts who tries to reach whom and can be drained, and against a recorder with a quantum computer a
> classical one-time key adds nothing. Rotation bounds the exposure instead.

#### 6.7.5 Rollout — readers first

A build that predates v4 refuses the version byte outright, so an invitation sent too early is lost
without a sound: the `J1154` failure. So:

1. **Readers.** Both apps decode and open v4 (rules 3 to 6) and send v3. Ship; confirm installed.
2. **Writers.** Both apps send v4. A recipient on a stage-0 build receives nothing, so this waits for
   stage 1 to be on the testers' phones.
3. **Automatic repair** (rule 7). Built, off (§6.7.6). Switched on only after stage 2 is on.

#### 6.7.6 Stage 3 as built: a broken link repairs itself (`J1678`)

Two halves, each behind its own switch, both shipped `false`. The rules are pure functions with the
same names on both platforms (`LinkRepair.swift`, `LinkRepair.kt`), held to one shared table,
`scripts/link-repair-cases.json`.

**"Judges broken"** is one predicate per platform, `LinkRepair.judgesBroken`, over evidence the app
already records for a contact:

- the contact is eligible (paired, unblocked, not this profile's own card, not an AI, not a
  group-only member; an ordinary person, or -- `J2480` -- one end of a link between this profile's
  own devices) and the card is **not crossed**; and
- **either** at least two envelopes on this profile's own collect token, attributed to their
  mailbox, opened under no key, the first and last at least an hour apart (`J1417`'s card re-send
  has had its hour and failed);
- **or** our card marked `want` went to them at least 24 hours ago and a ring placed at least an
  hour after that want still found no registration on the relay;
- **or** (`J2424`, *unanswered*) at least two of our one-to-one texts were handed to a transport
  since anything of ours was last acknowledged by their own session, the first at least an hour
  ago, and since then envelopes from them opened here under **their own** session (not one of
  their devices') at least ten minutes apart.

The first two arms are reset the moment any envelope from them opens under that card's session. The
third is reset only by a delivery or read receipt from their own session (their envelopes opening is
half of it), and all three by a repair being adopted. **The third arm only accepts**: the sender's
predicate, `LinkRepair.startsRepair`, is the first two arms alone.

**The receiver** (`acceptsProvenRepairs`), `LinkRepair.verdict`, in this order: not proven (a v4
whose first message opened under v2) → `notProven`; the identity is not byte for byte the key on
exactly one eligible card → `notAContact`; the card is crossed → `crossed`; the link is not judged
broken → `notBroken`; the sender prekey is neither the one in the bundle held for them nor the one
they publish now → `unknownPrekey`; the ephemeral key has been seen → `replayed`. Anything but
`accept` is an ordinary request, exactly as before -- except that a proven invitation from one of this
profile's own devices that is not accepted is dropped, never filed (`J2480`). The ephemeral key of **every** proven invitation
from a contact is recorded, taken or not, for 30 days (four times the relay's 7-day invitation TTL),
at most 1000 per profile, newest kept.

One narrowing beyond rule 7: when this phone sent its own proven renewal of the same link within
the last six hours, both phones are renewing at once, and each taking the other's session would
cross them again. The invitation from the identity that sorts first in byte order wins; the phone
whose own identity sorts first drops the other's invitation (`superseded`) instead of filing a
request.

**Judged on a core of its own** (`J2481`). Deriving an invitation's keys on the live core replaces
that core's contact for the identity it names, and a re-key drops the contact's ratchet book. So the
derivation and the first open that decide rule 4 run on a fresh core holding only this profile's
identity and no books; the live core derives the same keys only once the invitation is taken. The
same holds for a request's preview and for the stranger checks of `J2110` and `J1526`.

**Held, then checked** (`J2525`). A one-sided link leaves the side whose envelopes are refused with
no evidence at all: nothing of the other side's reaches it, or opens there, so no arm above can ever
hold, and the other side's renewal used to become a visible request. So a proven invitation that
passes **every** rule but the last -- identity byte for byte the card's (a link end's too, `J2527`), not crossed,
sender prekey held or published for that identity, ephemeral never seen, and not a downgrade (an
identity this phone knows speaks post-quantum is renewed only by a v5) -- is **held**, not filed and
not taken: no key moves and nothing is shown. This phone then checks the old link: its card marked
`want`, sealed under the session it holds now, which any build since `J392` answers silently.

- **Answered** -- anything of theirs opens here under their own session after the check went -- the
  old link works, and the held invitation is dropped, never filed and never taken.
- **Unanswered for 30 minutes** (`LinkRepair.probeWait`): that is this phone's own evidence that the
  link is broken. The held invitation is judged again, once, under every rule of `verdict` with the
  link taken as broken and its own ephemeral (recorded on arrival) set aside, and is adopted exactly as
  any repair is. Refused for any reason, it is dropped.
- One held invitation per contact (the newest proven one replaces an older), at most 50 in all,
  dropped untaken after 24 hours. Nothing new on the wire.

What it does not change: a held invitation is only ever one already proven for the pinned identity,
so an unauthenticated party -- a forged "unreadable" flood, a relay, a stranger -- gets at most a
check of the old link, never a key. A different identity is `notAContact` (the safety-number path, a
person's). The trade is stated: a holder of BOTH the contact's identity and prekey secrets, or of
this phone's prekey secret (§6.7.4), whose victim's real counterpart is silent for 30 minutes,
passes -- as it passes the first arm with a relay's help. Possession of those secrets is what every
rule here takes as identity.

**When the card asks the person** (`J2525`, `LinkRepair.needsPerson`). *Needs linking* appears only
when the app has failed: four renewals from this phone (`renewalsBeforePerson`, one per six hours)
and the link still broken, or -- when this phone cannot renew it (nothing of theirs published) and
only the other side's renewal can -- two days of evidence (`personAfter`). Until then the card reads
as being checked. Anything of theirs opening clears the count.

**Own devices** (`J2480`). On the phone, condition 2 for one of its own devices is the prekey in the
bundle that device signed after linking (the entry its device list announces), and nothing
published is consulted. The device end starts a renewal at a value its phone publishes.

**Prekey rotation and condition 2** (`J2895`, §6.7.4). Condition 2 compares against the one bundle
held for the sender and the one they publish now, as above. Until a phone first rotates no prekey
is superseded, so that comparison is unchanged; with it ON a superseded record is kept (pruning is OFF), so a link made against it still opens. The core already gives both ends what a rotation
needs: a sender proves with the record it names (`meshcore_prekey_ring_derive_sender_v2`), and a
receiver opens a renewal or carrier sealed to a superseded record by trying every record.

`J2527` extends this to both ends (`designs/J2527-device-link-repair.md`):

- **The phone may start** a renewal of its link to a device, on its own evidence (the sender's
  predicate, never the unanswered arm), written to that signed entry and nothing else, and carried
  through the old link (§6.7.7) -- its device publishes nothing. A device carries its renewal there too
  when no published value of its phone took it. Both senders sit behind
  `renewsOwnDevicesThroughOldLink` / `RENEWS_OWN_DEVICES_THROUGH_OLD_LINK`, shipped OFF.
- **Held, then checked, applies to link ends** as to anybody: the check (our card marked `want`) reaches
  a link end, whose app answers it, and anything of the link end's own opening drops the held renewal.
  A link end's renewal that is neither taken nor held is dropped, never filed.
- **Removed link ends.** Each profile keeps the identities of link ends it removed (Remove, an
  `unlinked` notice, Stop Receiving). `verdict` returns `notAContact` for such an identity before any
  other rule, so a removed device is never taken, held, carried or renewed towards; only a new in-person
  link clears it.
- **What needs a person.** A device restored from backup or moved by Transfer keeps its identity and makes
  a new prekey, so condition 2 refuses both sides' renewals; it is not relaxed, because a backup carries
  the identity secret and not the prekey secret, and whoever holds a copy could otherwise re-key the link.
  Once the app's own renewals are spent (`linkNeedsPerson`), each side's Shared Profiles row says what to
  do (`DeviceLinkRows`, `device-link-rows.json`). Nothing about this is on the wire.

**The sender** (`repairsBrokenLinks`, and only while `sendsProvenInvitations` is also on): when the
sender's predicate holds, the proven half of Renew Link runs without a tap: a v4 to the same identity at a
value on their card, only if that value still publishes that identity's bundle, and never a plain
invitation in its place. Never on a crossed card. At most once per contact in six hours and at most
five a day per profile, both recorded before the attempt and persisted.

#### 6.7.7 A renewal carried through the old link (`J2526`)

**Status, 7 October 2026: readers built on iOS and Android (on); the sender is behind
`renewsThroughOldLink` / `RENEWS_THROUGH_OLD_LINK`, shipped OFF, held equal by
`scripts/check-shared-tables.py`.** Vector: `vectors/32-link-renewal.json`.

**Why.** §6.7.6's renewal is deposited at a value the other side publishes. A contact who publishes
none cannot be sent one, and the side holding the evidence of a one-sided link (§6.7.6, "Held, then
checked") had nowhere to send it. But that evidence -- envelopes on this profile's own collect token,
attributed to that contact's mailbox -- proves the contact deposits at addresses derived from the
routing secret this phone holds for them, and so collects at the reverse direction of the same secret.
The routing secret and the message key are HKDF outputs of the same three inputs (§4.5), so equal
routing secrets mean an envelope sealed statically under this phone's session opens there.

**Payload type 55, `LinkRenewal`.**

- Body: exactly one pairing code, v4 or v5 (§6.7.2, §4.9), whose trailing bundle verifies for the
  header's identity, with an ephemeral key and a non-empty sealed message
  (`payload::check_link_renewal_body`). The core refuses any other type-55 body at encode AND decode;
  a core that predates the type refuses it at decode (`unknown payloadType`).
- **Always sealed static**, never inside the ratchet, whatever the ratchet switch says -- the
  local-network proof's rule (§4.5a) -- and **exempt from stage 3's static refusal**. Nothing about the
  carrier is believed for having opened under the pairing key: what a receiver acts on is the
  invitation inside, judged by its own proof (rule 4) and rule 7, exactly as one collected at a slot.
- One fragment, padded to a bucket like every envelope; deposited quietly (no ring, no banner) at the
  address the sender already deposits to for that contact. **The relay sees an ordinary envelope.**

**The sender** (only after every published value has been tried): the agreement `reach` makes for a
repair, to the bundle held for the contact (for an identity known to speak post-quantum, the bundle v2
its own card carried -- card field `pkb2`, verified for that identity and naming the prekey of the v1
bundle held -- or nothing: never a downgrade); the first message sealed on a core of its own
(`J2481`); the carrier sealed by the live core under the session it holds now; and only once the
deposit is confirmed does the live core take the new keys. Bounded by §6.7.6's sender bounds.

**The receiver** (`acceptsProvenRepairs`): a delivered `LinkRenewal` is handled before anything that
treats an opened envelope as proof of the link, only from the card that owns the session that opened
it (a person; or -- `J2527` -- one of this profile's link ends as a link end: a linked device holding
its signed entry, or this device's phone; never blocked), only when the invitation names that same
identity, and at most four per contact per hour (`LinkRepair.carriersPerHour`). It is then taken, held and checked, or
superseded, as §6.7.6 says; **anything else is dropped, never filed as a request.**

**What cannot heal this way.** When the near side's evidence is not from its mailbox (the want-and-ring
arm alone), or the two hold sessions with no routing secret in common, and the far side publishes no
value, nothing the near side can compute is an address the far side collects at. The carrier goes and
reaches nobody, and the card asks the person after `personAfter`.

**`J2526`, the DIFFERS arm.** A contact card from the contact's OWN session whose `mbc` disagrees with
the address this phone deposits to (`J1947`) is recorded as evidence that **starts** this phone's
renewal (`LinkRepair.startsRepair`) -- bounded like any -- and is never condition 4: it can never make
this phone take a key (a renewal from them meanwhile is held and checked). It is kept when their
envelopes open (that is how it is measured), cleared by an agreeing card, and voided by traffic proven
both ways after it.

#### 6.7.8 The pairing code's text format (`J2623`)

A pairing code travels as text when it is typed, pasted or sent through a messaging app. The text is NOT the
wire payload above; it is a rendering of those bytes, written once in `mesh-core-rs/src/pairing_code.rs`
(`text_encode`, `text_decode`, `text_split`, `text_join`), reached by iOS, Android and Windows through
`meshcore_pairing_text_encode`, `_text_decode`, `_split` and `_join`, and pinned by
`vectors/40-pairing-code-text.json`.

```text
alphabet   Crockford base32  0123456789ABCDEFGHJKMNPQRSTVWXYZ   (no I, L, O or U); the final character is
           zero-padded on the right; no '=' padding
whole code base32(payload || check)                                (check: 5 bytes, below; none for a payload under 41 bytes)
checksum   first 8 characters of base32(SHA-256(payload))         (over the WHOLE payload, never a fragment)
part       OM1-<i><n>-<fragment>-<checksum>                       i, n single ASCII digits, 1 <= i <= n <= 9
split      fragments of ceil(len/n) characters of base32(payload); a payload shorter than n still yields
           n parts, the trailing fragments EMPTY (OM1-33--CHECK), so "I need N" stays honest
join       per string: uppercase, then remove every Unicode white-space character; split on '-' into
           exactly four fields; field 1 = OM1; field 2 two ASCII digits i, n with 1 <= i <= n.
           Order does not matter and repeats are fine (the LATER copy of an index replaces the earlier;
           the whole-payload checksum is what catches a wrong one).
           Malformed  a string that is not a part, parts that disagree on n or on the checksum (two codes
                      mixed), nothing to join, or any bound below exceeded
           Incomplete fewer than n distinct indices: have of need (progress, never an error)
           Corrupt    all n arrived but the joined fragments are not base32, or the payload's checksum
                      differs (THE TAMPER SIGNAL: a channel was rewritten)
           Complete   the payload
```

**The whole-code check (`J2874`).** A code typed, pasted or read whole carries a five-byte check after the payload:
the first five bytes of `SHA-256("CHANNEL pairing code check v1" || payload)`. It applies to every payload of at least
41 bytes (the header: version byte, 32-byte identity, 8-byte nonce), whatever its version byte, because the version byte
is itself covered by the first base32 character. It is an integrity check against a mistyped, clipped or extended code, not
authentication and not a key; the payload is public, and splitting a code across channels (`join` above) remains the only
defence against a rewrite.

`text_decode` normalises, then reads **canonical** base32: a character outside the alphabet, a final character whose
leftover bits are not zero, and a spare character that holds no byte are all refused, so one payload has one spelling. It
then checks the last five bytes against the rest in constant time and returns the payload **without** them, or refuses
(`MESHCORE_ERR_REFUSED`, reason 601, for every cause; a person cannot act on the difference). A text shorter than the 41-byte
header is plain base32 as before, which keeps a bare 32-byte key from a build that predates the rendezvous recognisable by
`is_legacy`.

A code written by a build that appends no check is accepted, whole and unchanged, only while the constant
`pairing_code::ACCEPT_UNCHECKED_WHOLE_CODES` is true (the vector file states the value it was generated for), and only when
`pairing_code::payload_len` (where the version's fields end: header, name, ephemeral key, sealed message, the version 4 bundle
or the version 5 bundle and ciphertext) equals its length or leaves exactly one well-formed `sender_stamps` trailer. A reader
that knows nothing of the check reads a checked code as it always read a code: the five bytes are trailing bytes, which
`decode` ignores (`J72a`), after any stamps trailer.

**Bounds (hostile input).** A payload to encode or split: at most 32 KiB. Any one string to decode or join:
at most 64 KiB before normalising. At most 256 strings in one join. `parts` outside 1 to 9 is refused. These
are checked before anything is normalised or allocated.

**Known leniencies.** In the split form, bits left over after the last whole byte are dropped whatever they are, so two
texts that differ only in those bits decode to the same payload (the checksum is over the payload, not the text). A repeated
index with different text is not an error by itself. The whole code is canonical (above).

### 6.8 The mail seal — email arriving as sealed bytes (`J3015`)

An email bridge receives mail for a profile's handle and must hand it to that profile's phone without being able to
read it afterwards. The bridge is nobody's contact, so the chat seal (which needs a paired session) cannot be used;
this section seals to a public key instead. **It is not new cryptography:** it is RFC 9180 HPKE base mode,
DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20-Poly1305 — the construction §5.8h already uses for the recovery
bundle — with a label of its own. Reference: `mesh-core-rs/src/mail.rs`; known answers: `vectors/56-mail-bridge.json`,
written by `vectors/generate.py`'s own HPKE and checked by `cargo test --test conformance mail_bridge`.

**The mail key.** A profile whose email is on (either of its two email permissions, `J3536`) makes one fresh X25519 pair `(ms, MS)` that is used for nothing else
(never in a prekey bundle, never in an agreement). `MS` is registered with the relay; `ms` stays on the profile's
devices. The relay holds `MS` and does the sealing, so it could seal to a key of its own choosing; what it cannot do is
read an item after it is sealed.

**The seal.**

```
info   = "channel/mail-bridge-v1" || recipient identity (32 bytes) || MS (32 bytes)
aad    = empty
sealed = enc (32) || ChaCha20-Poly1305 ciphertext of padded at sequence 0 (len(padded) + 16)
padded = u32 big-endian length of item_cbor || item_cbor || zero bytes
```

**Padding.** `padded` is exactly one of these sizes, the smallest that holds `4 + len(item_cbor)`: 4,096; 16,384;
65,536; 262,144; 1,048,576; 4,194,304; 8,388,608; 16,777,216; 25,165,828 (24 MiB plus the 4-byte length). A sealed
item's length therefore names a bucket and nothing finer. A reader refuses a `padded` whose size is not a bucket,
whose bucket is larger than needed, whose length runs past the plaintext, or whose padding is not all zero.

`enc` and the key schedule are RFC 9180 `SetupBaseS(MS, info)` with the ephemeral key from `DeriveKeyPair(ikm_e)`,
`ikm_e` being 32 random bytes the sealer supplies (the core reads no random source). The recipient identity and `MS`
are both in `info`, so an item opens only for the profile and the key it was sealed to: a copy placed in another
profile's inbox, or opened under a rotated key, fails authentication and yields nothing.

**The item** is one canonical CBOR map (§1.1) with keys 0 to 15, **all present, in ascending order, nothing after
the map**: 0 version (1); 1 recipient handle (`[a-z0-9_-]`, 3 to 20; a separator never starts, ends or doubles); 2 sender address (1 to 254 bytes); 3 sender name
(at most 64 characters); 4 subject (at most 200); 5 text (at most 16,000); 6 received-at, relay time in seconds; 7
authentication flags (bit 0 SPF pass, bit 1 DKIM aligned, bit 2 DMARC pass, bit 3 bulk headers; higher bits refused); 8
junk score (0 to 100); 9 links, at most 50, each a map `0 shown` (at most 512 characters), `1 real` (at most 2,048
bytes), `2 host` (at most 253 bytes), `3 flags` (u8); 10 pictures, at most 4, each a map `0 mime`
(`image/jpeg`, `image/png` or `image/heic`), `1 bytes` (at most 5 MiB, and the leading bytes must be the format the MIME names);
11 reply ticket (32 bytes); 12 thread key (32 bytes); 13 count of refused parts (at most 65,535); 14 message id (16 random bytes the Worker chooses, so a duplicate delivery can be dropped); 15 reply references (`J3256e`, 22 Oct 2026): an array of at most 3 text strings, each a message-id (below), the email's own `Message-ID` first and then the ids of its `References` the Worker kept, newest last; the array may be empty. The whole encoding is
at most 24 MiB.

**Message-id (key 15 and the send's `inReplyTo`).** `<left@right>`, 5 to 255 bytes, brackets included and no other bracket; exactly one `@` with both sides non-empty; every other byte an ASCII letter, digit or one of ``!#$%&'*+-/=?^_`{|}~.``. So no space, control character (NUL, CR, LF, tab), DEL, quote, backslash, comma, colon, semicolon, non-ASCII byte or second id can be carried, and a header can never be split through one. More than 3 entries, a longer or malformed id, a non-array, or a non-text entry refuses the whole item. The array count and each string length are checked before anything is copied. **Compatibility:** a reader that knows only keys 0 to 14 refuses a 16-key item, and this reader refuses a 15-key item; older builds do not open mail sealed by the new Worker (owner ruling 8 Oct 2026: compatibility is skipped when it is costly; the beta compatibility switches are removed at public release).

**Content rules.** The sender address, name, subject, text and every link field contain no C0 control character, no DEL
and no bidirectional override or isolate (U+202A to U+202E, U+2066 to U+2069); the text alone may contain a newline.
Inbound, those fields also contain no LRM or RLM (U+200E/F), no Arabic letter mark (U+061C) and no zero-width character
(U+200B to U+200D, U+2060, U+FEFF); the Worker strips them, so an item carrying one is malformed (`J3280e`).
A link's `real` is `http://` or `https://` (any case) with no userinfo, no backslash and no `%` in the authority, and at most
a numeric port, and its host equals `host` ASCII case-insensitively, so a label cannot name one site and open another.
Outbound text follows the first character rule only (controls, DEL, overrides and isolates), because a person types it and
U+200D and U+200C are part of emoji sequences and of Persian. JSON written by the core escapes `"`, `\`, newline, carriage return, tab, backspace (`\b`) and form feed
(`\f`) by name and any other control as `\u00xx`, as JavaScript and Python do.

**Bounds before allocation.** A sealed item longer than the largest bucket plus 48 bytes is refused before anything is decrypted;
one shorter than `enc` plus a tag is refused as truncated; every string and byte string is checked against its bound
before it is copied; an array's declared count is checked before any element is read. A non-canonical encoding
(non-shortest integer or length, indefinite length, keys out of order or repeated, an unknown or missing key, a
float, trailing bytes) is refused, as is any field outside its rule above. `vectors/56` lists encodings every reader
must refuse.

**Outbound.** The apps send through the Worker's `/v1/send` with a JSON body
`{"inbox","collect","to","text","subject":text|null,"replyTicket","threadKey","inReplyTo":[msg-id,...],"pictures":[{"mime","b64"}]}`. `J3345`: the app passes the subject of the mail it answers (or null) and the core emits `"Re: <it>"` through `reply_subject` (tags and control characters removed, one `Re:`, at most 180 UTF-16 units) or null when nothing usable is left; the Worker applies the same rule again and sends the fixed subject for null. Table: `vectors/shared/mail-reply-subject.json`. The core validates it first and
re-writes it (`send_request`): `inbox` 32 lowercase hex and `collect` 64 lowercase hex; `to` a plain ASCII address (one
`@`, no quotes, comments or whitespace, a dotted domain); text 1 to 8,000 characters with at most 5 links
(`http://` or `https://`); at most 4 pictures, JPEG only, each at most 5 MiB; the two keys 64 hex or `null`; `inReplyTo` at most 3 message-ids as above (empty for a fresh mail; an app passes the reply target's key-15 list, last id as the one answered). The Worker re-validates it and sets `In-Reply-To` to the last entry and `References` to all of them, and sets neither when the list is empty or any entry is invalid; it stores nothing. When the
permission the app passes is off (`permitted` false, passed as a value) the core refuses before reading the request, and
likewise refuses `meshcore_mail_open`. `J3536` (owner, 27 Oct 2026): a profile has two email permissions, **A** (email with
people outside CHANNEL) and **B** (email as a backup route between CHANNEL users), both on by default and both off by default
in Secure Mode, each of which can be turned back on there (`channel-algo` `route_gate` op 3 derives them). A plain request
needs A; the open needs either, and the app then files mail from a matched CHANNEL contact only with B and any other mail
only with A.

**Sealed outbound (`J3445`).** To a linked CHANNEL contact's handle address the app sends, instead of text, `"envelopes"`:
1 to 64 ordinary sealed envelopes (§2) of at most 64 KiB each, base64, with `"text"` null. They are the same envelopes the
relay would carry, so the mail services on the way see the two handle addresses and the time, never the message. The
Worker accepts envelopes only for a handle address on one of its receiving domains, and only when that handle has a mail
route at the relay; otherwise it answers 404 `{"reason":"no-recipient"}` and sends nothing. It sends them as one mail with a
fixed text and the envelopes as an `envelopes.chnl` attachment (`application/x-channel-envelopes`); the receiving Worker
deposits them on the recipient's ordinary queue. Tests: `mail-worker/test/outbound.test.ts`, `mail-worker/scripts/e2e-local.mjs`.

### 6.8a The mail envelope — mail as an ordinary envelope on the ordinary queue (`J3119a`)

The bridge holds nothing (owner, 19 October 2026). Incoming mail is sealed in memory into one or more **Envelope v1**
(§2) that have the shape, the widths and the `payloadLength` of an ordinary sealed message in the same padding bucket,
filed under a mailbox token from the ordinary function (§4, `mesh/mailbox-v1`), and deposited at once on the ordinary
queue. A stored mail envelope does not say it is mail; the phone learns that only after opening it. While the relay's
switch for it (`MAIL_AS_ENVELOPE`, default off) is on, this format replaces §6.8's single sealed item on the wire; apps read
both. §6.8's item map is unchanged and is what the envelopes carry.
Reference: `mesh-core-rs/src/mail_envelope.rs`; known answers: `vectors/57-mail-envelope.json`, written by
`vectors/generate.py`'s own HPKE, PRF and CBOR and checked by `cargo test --test conformance mail_envelope`.

**Route secrets.** A profile whose email is on holds, beside `(ms, MS)`, a 32-byte **mail routing secret** `mr`,
used for nothing else. With direction byte `0x00`: the mailbox token is `mailbox_token(mr, 0x00, mailboxEpoch)` and
the destination token is `destination_token(mr, 0x00, creationBucket)` — the same functions, labels and clocks as a
contact's (§4.3, §4.5). The phone collects its mail tokens in the same batched collect as its contacts' tokens.

**A part.** The **stream** is the §6.8 item CBOR exactly (pictures inline; text is never cut). It is cut into slices of
62,848 bytes (the last one shorter). Each slice is one **part**, a canonical CBOR map with keys 0 to 6, all present, in
order, nothing after it: 0 `payloadType` = **61**; 1 `mailId` (16 bytes, equal to the item's key 14); 2 `index`;
3 `count`; 4 `streamLength` (1 to 25,165,824); 5 `streamDigest` (SHA-256 of the whole stream); 6 `bytes` (the slice).
`count` must equal `ceil(streamLength / 62,848)` (so at most 401), `index < count`, and the slice must be exactly 62,848
bytes for every index but the last and the remainder for the last. All of these are checked on the numbers before the
slice is read. Payload type 61 is reserved in the payload registry and is valid ONLY here: §5's decoder refuses it, so a
contact cannot forge a mail into a chat. The next free payload type is 62. Index 0 is the **head**; a sealer emits it
last so a depositor that stores in order stores the head last.

**Size classes.** `padded` is the part followed by zero bytes to EXACTLY `L(b) - 48`, where `b` is the smallest of
1,024, 4,096, 16,384, 32,768, 62,976 for which that holds the part, and `L(b)` is the `payloadLength` of the commonest
ordinary envelope in bucket `b`: a ratcheted text, `b + 103` (marker 1, ratchet header, nonce and tag 86, envelope tag
16), below the large bucket; a streamed attachment chunk after chunk 0, `b + 61`, in it. So `payloadLength` is one of
1,127, 4,199, 16,487, 32,871, 63,037. A reader refuses any other length, a part that a smaller class would hold, a part
followed by a non-zero byte, and bytes after the part that are not zero. `cargo test` pins the equality against the
core's own ratchet and chunk seals, both ways.

**The header.** `protocolVersion` 1; `packetID` 16 fresh random bytes; `destinationToken` as above; `creationBucket` =
`floor(time / 600)` at the sealer (relay time); `expiresAfter` 144 and `transitPolicy` 0, the values an ordinary
mailbox-bound envelope carries; `payloadLength` as above; one fragment (`0`, `1`, `totalLength = payloadLength`);
`hopLimit` 0. A reader refuses a mail envelope with any other `expiresAfter`, `transitPolicy` or fragment fields, and
one whose destination token is not `destination_token(mr, 0x00, creationBucket)` of its own creation bucket. How old an
envelope may be is the relay's TTL; the reader reads no clock.

**The seal.** No new primitive: RFC 9180 HPKE base mode, DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20-Poly1305,
as §6.8; HMAC-SHA256 as a PRF.

```
info       = "channel/mail-envelope-v1" || recipient identity (32) || MS (32)
aad47      = §3.1 over the header above
nonce      = HMAC-SHA256(r, "channel/mail-envelope-nonce-v1" || aad47 || padded)[0..12]     (the header's key 11)
ikm_e      = HMAC-SHA256(r, "channel/mail-envelope-ikm-v1"   || aad47 || nonce || padded)
ciphertext = enc (32) || ChaCha20-Poly1305 of padded at sequence 0 with aad = aad47 || nonce   (the header's key 13)
```

`r` is 32 fresh random bytes per envelope. The ephemeral key and the visible nonce are derived from the COMPLETE AEAD
input (header, nonce, plaintext), so a sealer that reuses `r` across two different inputs still uses two unrelated
ephemeral keys and no (key, nonce) pair ever seals two inputs; the same input with the same `r` reproduces the same
bytes, which is harmless. Every envelope is sealed independently and opens alone. The new label means a §6.8 item
never opens as an envelope, nor the reverse. What the seal does not prove: who wrote the plaintext — anyone holding
`MS` can seal (§6.8).

**Reassembly.** Parts of one mail must agree on `mailId`, `count`, `streamLength` and `streamDigest`; a second copy of
an index with identical bytes is dropped, with different bytes the mail is discarded. When every index is held the
stream is concatenated, its SHA-256 must equal `streamDigest`, it must decode as a §6.8 item under every §6.8 rule, and
the item's key 14 must equal `mailId`. A mail with a part missing is never shown. Apps hold opened parts only until
their mail is complete and confirm the envelopes then; an envelope that fails to open is confirmed away.

## 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 the owner'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 (the owner's sketch) |
| `ROUTE_STATE_TTL` | 15 s | Held-route lifetime (the owner'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.7a Taking custody — the carrying rules (`J1804a`)

A node takes custody of an envelope that is not addressed to it — by an encounter (§6.2) or because
its host heard it on any transport and it opened under no key — under **one** set of rules. They
apply to the envelope as received; **no field of it is authenticated to a carrier**, so every rule
below is a local bound on the carrier's exposure, never a judgement about the sender. In order:

1. **Never its own mail.** An envelope whose fragment this node has already opened, or whose
   `destinationToken` is one of its own destination tokens for any epoch within ±2 of its own
   (the ±1 accepted window, §4.4, about each of epoch − 1, epoch and epoch + 1), MUST NOT be
   carried. A host MUST additionally not offer for carrying an envelope that reached it by its own
   mailbox or its own direct link: those carry only mail meant for it, under tokens that may be
   older than any window the core can recompute.
2. **Not expired** (§2.2): `nowBucket ≤ creationBucket + expiresAfter`.
3. **Not already carried.** Duplicate suppression by fragment key *and* destination token, for
   `MAX_RELAY_LIFETIME` (§6.4). **Recorded only when the envelope is taken** — a copy refused for
   any reason below leaves no trace, so a forged copy (the hop limit is unauthenticated) cannot
   make the genuine one a duplicate.
4. **Relay mode on.**
5. **Hops remain**: `hopLimit ≥ 1`.
6. **A bounded lifetime.** `expiresAfter ≤ MAX_RELAY_LIFETIME` (144 buckets), and `creationBucket`
   at most one bucket ahead of this node's own — otherwise a stranger's word holds the envelope
   past §6.4's bound.
7. **Room.** At most `MAX_QUEUED_ENVELOPES` (512, `J2657`; it was `MAX_INVENTORY_ENTRIES`, 128) envelopes
   held, own mail and carried mail together; expired ones are released at every take, before the count is
   taken. With `MAX_ENVELOPE_BYTES` (§2.3) that bounds the queue at 32 MiB, inside `MAX_RELAY_STORAGE`.
   A node's own mail stops at half of it, `MAX_ORIGINATED_QUEUED` (256): one message of up to 16 MB.
8. **First come stays.** A second envelope under a fragment key already held — whatever its
   token — is refused, never substituted: a carrier cannot tell which copy is genuine.

On taking it, the carrier stores the envelope with
`hopLimit = min(hopLimit, MAX_ACCEPTED_HOP_LIMIT) − 1` — the clamp §2.2 requires, then the hop
spent. Every other byte is unchanged.

A host is told which rule decided — taken, or refused as *full*, *duplicate*, *own*, *hops*,
*expired* or *off* — and **counts only taken as carried**. An originator's own outbox is not under
these rules (§6.4); it is a separate entry point and is never used to carry for others.

**The ACK table (`J1804f`).** In an encounter, `ACK{accepted: true}` means exactly one thing: the
receiver has taken the envelope, now or before, so its sender may let its copy go. It is answered
for an envelope that opened, one addressed to the receiver and taken into its own reassembly
awaiting sibling fragments, one taken to carry, and one the receiver already took — already
opened, or already carried under this fragment and token (rule 3), whether or not that carried
copy has since been handed on. **Every refusal is answered `accepted: false`** and the sender keeps
its copy: one for the receiver's own tokens that does not open (rule 1), one that fails to
authenticate, expired (rule 2), relay off (rule 4), **out of hops (rule 5)**, past the lifetime
bound (rule 6), a full queue (rule 7) and a conflicting copy (rule 8). A copy refused here may
still reach its destination another way — a hop-spent envelope can still be handed to the
destination itself, which applies no hop rule — so an `accepted: true` for a refusal threw away mail
that was still deliverable. A sender's own expiry sweep, not a peer's verdict, ends an expired copy.

The bit is not an oracle the receiver did not already offer. An envelope under one of the
receiver's own tokens that fails to open is answered `false` where a stranger's would be taken and
answered `true` — but a stranger's that is taken also appears in the receiver's next `INVENTORY`,
and one under the receiver's own token never does, so whoever can send an envelope and read an
inventory could already tell the two apart. With relay off, WANT itself names the receiver's own
tokens. Both are properties of carrying for others at all, stated here rather than claimed away.

**WANT asks only for what would be taken.** A node judges each offered `(packetID, token)` by the
same rules: its own mail (a destination token in the ±1 window it opens under) is wanted unless
already opened, needs no room, and is asked for **before** anybody else's, so the per-encounter cap
is never filled with strangers' mail ahead of it; mail for one of its tokens outside that window
but within ±2 is never wanted; anybody else's is wanted only with relay mode on, never when already
carried under that fragment and token, never when the fragment is held under *any* token (rule 8),
and only within the room unexpired carried mail leaves — expired mail is released at the next take,
so it counts for neither. A key is asked for once whatever
tokens it was offered under. An inventory entry does not show `hopLimit`, `expiresAfter` or
`creationBucket`, so rules 2, 5 and 6 can only be applied on arrival; such an envelope is asked
for, refused, and stays with its sender — the cost is one envelope's transfer, never the mail.

### 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-07 in the field: the shared-LAN rung carries messages between platforms

The owner's iPhone (iOS 26) and his own Android handset, on one Wi-Fi network, in normal use of the
shipped apps. A message between them was delivered and the receiving phone drew a green `L` against
the thread -- the first time that mark has been seen for real traffic rather than in a test.

What this measures, and what it does not. It measures that `J21` rung 3 works end to end across the
two platforms: discovery, the authenticated LAN handshake (`LANPeerAuthenticator`), and delivery,
with the route mark reported honestly. It does not measure range, behaviour on a network that
isolates clients, or anything about backgrounded delivery. Those remain 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 desktop node (`DECISIONS.md` F9, J2) 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. The owner 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 (late mail: §4.6) |
| `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` | 65536 | Checked before allocation |
| `MAX_FRAGMENTS` | 16 | Checked before reassembly |
| `MAX_DECOMPRESSED` | 32768 | Enforced *during* streaming decompression |
| `PADDING_BUCKETS` | 256 / 1K / 4K / 16K / 32K / 62,976 / 65,024 | 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 | Entries one INVENTORY lists; the wire bound |
| `MAX_QUEUED_ENVELOPES` | 512 | Queue a node holds (`J2657`); own mail is half of it |
| `MAX_GROUP_MEMBERS` | 24 | Enforced; fan-out cost is N× (§5.2). Raised from 6 by `J902` |
| `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."*


