CHANNELIn testingNotify me
Help

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

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.

KeyNameTypeWidthIn AADMutable in transit
----:-------------------:------::------------------:
1protocolVersionuint1 byteyesno
2packetIDbstr16 bytesyesno
3destinationTokenbstr16 bytesyesno
4creationBucketuint4 bytesyesno
5expiresAfteruint1 byteyesno
6transitPolicyuint1 byteyesno
7payloadLengthuint2 bytesyesno
8fragmentIndexuint1 byteyesno
9fragmentCountuint1 byteyesno
10totalLengthuint4 bytesyesno
11noncebstr12 bytesnono
12hopLimituint1 bytenoyes
13ciphertextbstrvariablenono

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.

ValueMeaning
------:---------
0Local only. MUST NOT traverse an internet gateway hop. Default.
1Any 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):

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.

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.

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

2.3 Size bound

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

---

3. AAD construction

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

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

3.1 Layout — exactly 47 bytes

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

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

Excluded and why:

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

PurposeAlgorithm
Identity signingEd25519
Key agreementX25519
Key derivationHKDF-SHA256
Token derivationHMAC-SHA256, truncated
AEADChaCha20-Poly1305, 96-bit nonce, 128-bit tag
HashSHA-256

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

4.2 Pairing output

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

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

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

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

4.3 Routing secret

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

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

4.4 Presence and destination tokens

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

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

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

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

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

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

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

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

4.5 Message key

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

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

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

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

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

This is CryptoSuite v1's static composition, 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 Signal's Double Ratchet (The Double Ratchet Algorithm, Perrin and Marlinspike, revision 1) inside the envelope, so that stealing a phone's state today does not open messages it has already read, and a ratchet state stolen once stops working after one round trip against an attacker who only reads. Implementation: mesh-core-rs/src/ratchet.rs, the only one.

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. 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, nothing added. 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, so a session's first chain is only as secret as the pairing, as X3DH's first messages are until a reply. 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 sessionIds 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):

header    = version 0x01 ‖ flags ‖ sessionId 16 ‖ DHs public 32 ‖ PN uint32 BE ‖ N uint32 BE    = 58 bytes
flags     : 0x01 INITIAL, 0x02 SIGNED; 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 58 header bytes ‖ "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(header 58 ‖ nonce 12 ‖ ciphertext ‖ tag) 32)

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

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, and still no message.

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.

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] ``

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

  1. 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:

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:

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 as the one picker: v5 is opened with v3 and nothing else). 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: there is no negotiation and no optional PQ.

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

PurposeAlgorithm
Identity signingEd25519 (unchanged)
Key agreementX25519 and ML-KEM-768 (FIPS 203), always both
Key derivationHKDF-SHA256 (the combiner too)
Token derivationHMAC-SHA256, truncated
AEADChaCha20-Poly1305
HashSHA-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 reads only the length and the version byte.

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

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

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

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

stampthe hashthe relay signsobtained 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:

KeyNameTypeNotes
----:-------------------
1senderIdentitybstr 32Ed25519 identity public key. Sealed sender — visible only to the destination.
2sequenceuintMonotonic per sender per conversation (SPEC-ORIGINAL §21).
3sentAtuintSender's wall clock, seconds. Safe here; never in the Envelope.
4payloadTypeuint1 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 (§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)
5compresseduint0 none · 1 Brotli with CompressionDictionary v1.
6policymapSender's current settings (§5.5).
7bodybstrUTF-8 text, or a receipt structure, per payloadType.
8groupIdbstr 16Optional. Present for group messages (§5.2).
9groupDigestbstr 8Optional. Membership digest, so divergence is detectable (§5.2).
10messageIdbstr 16Optional. The sender's own identifier for this message, stable across re-seals (§5.1a).
11groupCreatorbstr 32Optional. Present on group payloads; the identity that established the thread (§5.2).
12groupRosterarrayOptional. The full roster, when §5.2.4 requires it.
13groupNametstrOptional. At most 48 bytes, cut on a character boundary (§5.2).
14groupRemovedbstr 32Optional. The identity removed, on payloadType 16 (§5.2).
15replyTobstr 16Optional. The messageId (§5.1a) of the message this one answers (§5.19). Only beside key 10.
16aiAuthoruintOptional. An AI wrote this message's text; the value is the colour its owner chose for it, 0xRRGGBB, at most 0xFFFFFF (§5.28).
17linkPreviewmapOptional. The sender's preview of the first link in the text: title, summary, site and a small JPEG (§5.29).
18attachmentGroupbstr 16Optional. 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).
19sentAtMillisuintOptional. 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.
22introuintOptional. 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.
23commandbstr 24Optional. 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.
20urgentboolOptional. 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.
24forwardedboolOptional. 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.

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.

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.

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 platform-encoded body, not CBOR. It is not a core codec: the Swift (Receipt.swift) and Kotlin (Receipt.kt) codecs are identical and are held to the same bytes by vectors/50-batched-read-receipt.json. (Moving it into the core would add an FFI, a JNI and a UniFFI surface for a fixed layout of three fields, and the id it names, SHA-256(envelope)[0..16], is already computed in the apps.)

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

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.

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.

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

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.

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

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.

Two rules an implementation must not omit:

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

#### Status, 1 October 2026

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

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

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

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

rulerefusal (code)
issued_at <= now + 600 sFromTheFuture (7)
seq <= H.seq: same seq and prev, other body = sibling; otherwise replaySibling (4), Replay (3)
seq = H.seq + 1 exactlySeqSkip (5)
prev = SHA-256(H body)WrongParent (6)
issued_at >= H.issued_atClockBackwards (8)
every bundle verifies and names its own keyBadBundle (9)
no listed key is tombstonedTombstoned (10)
every signature verifies over the bodyBadSignature (11)
every signer is a device of H (the newcomer never signs)SignerNotHeld (12)
every device of H that is gone is tombstoned hereOmittedWithoutTombstone (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 unchangedSinceMismatch (15)
a kept device's bundle changes only with that device signing (stricter)BundleSwap (16)
no devices left only from a one-device HEmpty (18)
membership change (anyone added or removed): at least min(2, devices of H) signers of H with since < H.seqQuorum (17)
no membership change (a refresh): one signer of H, any since--

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; leave 162; bundle 293; distrust 199; Master Key set by the only host 226; 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)                     -- J1866f
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 11 and 12 only

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 11 and 12). 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.

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

  1. 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.
  2. 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.
  3. Nothing by an author with a seq above its own leave counts.
  4. Ever admitted: the root's keys, then every key added by an ever-admitted author.
  5. 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.
  6. Admitted: the root's keys, then every key added, by a statement still counting, by an admitted author.
  7. Removed, for ever: every key that left, was distrusted, equivocated, or was removed by rule 0 -- or (J1866f) by the Master Key (rule 9).
  8. 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 with the lowest at, then the lowest SHA-256 of its body.
  9. Hosts: admitted minus removed, ordered by admission (at, then hash; root keys first); 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.

  1. 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 host key that signed a leave does not speak here. b. A set by the only host (kind 6) counts when the roll proves its author was then the only host: following every add from the root keys, except the author's own adds above that statement's seq, reaches the author and no key but keys that signed a leave. (So the owner sets it on a new profile and shares afterwards; the app offers it at creation, before the first share.) 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): a kind-11 set whose stamp is at or after the moment another unrefused kind-11 set took effect (its stamp + 48 h) is void, never in effect and never pending. Two sets stamped while neither was yet in effect still contest. Sets still within their 48 hours and unrefused 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 lowest seq 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.
  2. 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).

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.

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

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:

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

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:

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:

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:

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 and the statements posted to the recovery mailbox are not specified yet.

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.

outputIKMinfo
Ed25519 seed of Rthe 16-byte secretchannel/recovery-v1/signing
R--the Ed25519 public key of that seed (RFC 8032)
bundle sealing keythe 16-byte secretchannel/recovery-v1/bundle
recovery mailbox tag, 16 bytesR (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 listed by the root or added by such an author (repeatedly); 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.

Beyond Apple's list: the COSE key equals the leaf key, and the CA flags are checked.

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

KeyDescription (OID 1.3.6.1.4.1.11129.2.1.17), in the leaf only:

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

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

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:

keyvalue
chainbase64 of one §5.8b list (a commit or genesis), at most 5,062 bytes decoded
chainLinksbase64 of a §5.8b listing encoding (at most 128 links)
chainFrom64 hex: SHA-256 of the asker's head body; the answer is chainLinks after it
root64 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
resolvebase64 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.

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:

typebody
CallOffer22the caller's SDP offer and the fingerprint of the media keys
CallAnswer23the callee's SDP answer, sent only after a person accepts
CallCandidate24one ICE candidate, or a small batch, from either side
CallEnd25declined, 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:

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

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:

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.

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:

PayloadTreatment
text (1), groupMessage (13)May announce. A banner and a sound.
callOffer (22)May ring. Once per call, and never a banner.
everything elseSilent. 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:

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

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>", ...] }

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>" }

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

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

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

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

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

FieldTypeNotes
identitybstr 32Ed25519 identity public key.
prekeyBundlebstr 129The §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

TypeMeaningWho may send
13Group messageany participant
14Left the threadanyone, for themselves only
15Thread endedthe creator only
16Removed a participantany participant
17Roster requestany 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

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

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

TypeMeaningSent bySent to
44Group invitationa participantthe invitee only, pairwise
45Invitation acceptedthe inviteethe inviter only, pairwise
46Invitation declinedthe inviteethe 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.

TypeMeaningSent bySent to
47Group introductionthe initiating member, then the relaying memberthe 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:

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

KeySettingStricter direction
----:-----------------------------
1maxHopLimitlower
2maxRelayLifetimelower
3minPaddingBuckethigher
4allowGatewayTransitfalse
5disappearAfterlower (0 = never, which is the weakest)
6allowCompressionfalse

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:

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.

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

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

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:

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>", ...}

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)

J1659. The owner, 27 September 2026: Updates, contacts only, for 24 hours.

A person's updates, each sealed pairwise to each of their contacts, exactly as a contact card travels (§5.7): the same session, the same seal, one envelope per contact. The relay sees what it sees for any quiet deposit and nothing more. Never filed as a message, never notified, never rung, never in a group (is_group and is_call are both false).

The core seals the body without parsing it (payload type 33 is opaque to the seal). The body's own format, and every bound a reader applies, is the core's too (J2625): mesh-core-rs/src/status_update.rs reads and writes it, vectors/41-status-update.json (made by an independent encoder) pins it, and iOS, Android and Windows reach it through meshcore_status_update_decode / _encode (JNI: statusUpdateDecode / statusUpdateEncode) rather than each keeping a copy.

uint32, big-endian   n, the length of the header (1 to 4,096)
n bytes              UTF-8 JSON header
the rest             a JPEG, present only when the header says "img":true
{"v":1, "id":"<uuid>", "at":<ms>, "exp":<ms>, "text":"...", "bg":<0-7>, "font":<0-4>, "img":true, "del":true}

A receiver keeps every live update from a sender, up to 30, dropping the oldest, and shows them in at order. A new id is added; a known one is ignored. It discards:

An update is deleted when it expires, by the receiver and by the sender. A receiver names what it stores by the sender and the id together, never by the id alone, which the sender chooses.

Which of these are the format's and which are the phone's. The core applies the format: sizes, the JSON, v, id, the times against a clock it is handed (it reads none), bg, font, the media object, the picture, and that an update says something. The phone applies what it keeps: at most 30 per person and dropping the oldest, ignoring a known id, deleting at expiry, and the file names. The 700-character rule is applied twice: a character is a grapheme cluster, and counting one needs Unicode segmentation tables the core does not carry, so the core refuses what is certainly too long (more than 700 ASCII characters, a CR LF pair counted once) and each app then applies its own platform's exact count to what the core returned, discarding the whole update past 700.

5.23.1 The header as the core reads and writes it (J2625)

Reading is strict, because the header comes from another device. It is one JSON object (RFC 8259 syntax only), valid UTF-8, with nothing before or after it but white space. Numbers v, at, exp, bg, font, media.len and media.dur are integer literals that fit 64 bits: 1.0, 1e0, +1 and 01 are refused. A key that appears twice in one object is refused, however it is spelled. Nesting past 64 levels, counting the header object itself, is refused. A string may not hold a raw control character or a lone surrogate escape. A key the format does not know is skipped, whatever its value. null is the same as absent for text, bg, img, del, font, media, lp and a preview's t and d. v, id, at and exp are required, and v must be 1.

After the syntax, in this order: a withdrawal ("del":true) answers id, at and exp and nothing else, and must be followed by no bytes; otherwise at and exp are not below zero, at is no more than 5 minutes after the reader's clock, exp is after at by at most 24 hours and 5 minutes and after the reader's clock; text passes the 700-character rule above; bg, if present, is 0 to 7; a font outside 0 to 4 is dropped, the update kept; a media object, if present, has kind v or a, addr 32 and sha 64 lowercase hex digits, key canonical standard base64 of exactly 32 bytes (44 characters, one =, zero trailing bits), len 1 to 33,554,432 and dur 1 to 60 (v) or 1 to 30 (a), and a video also has "img":true; "img":true means the rest is a JPEG of more than 2 bytes starting FF D8, otherwise there is no rest; and with no media, text must not be only white space.

Writing. The header is the JSON of Swift's JSONEncoder with sorted keys, which every platform now produces by calling the core: keys in code-point order (at bg del exp font id img lp media text v; a media object addr dur key kind len sha; a preview d t u), an absent field left out, no white space, / written \/, " and \ escaped, \b \f \n \r \t and other control characters as \u00xx in lower case, everything else raw. v is always 1. A writer refuses an id that is not a UUID, a bg past 7, a font past 4, text past the 700-character rule, a picture that is not a JPEG or whose presence disagrees with img, a media object out of range, a withdrawal that carries anything but id, at and exp, a header over 4,096 bytes and a body over 32,768. The reasons are numbered 701 to 712 in meshcore.h.

The record. A decode answers, and an encode takes, one binary record rather than JSON (meshcore.h): flags (1 withdrawal, 2 picture follows, 4 text, 8 bg, 16 font, 32 media, 64 preview), the id as 36 ASCII bytes, at and exp as big-endian signed 64-bit, then the fields flagged, in that order. A decode appends the JPEG after the record; an encode takes it as its own input.

What is tightened against the first Swift and Kotlin readers, for anyone comparing: integers only (both readers took 1.0); duplicate keys refused (Swift kept the first, Kotlin the last); bytes after a withdrawal refused (both ignored them); an unpadded or non-canonical key refused (Kotlin took it); a header that is not valid UTF-8 refused (Kotlin replaced the bad bytes). A sender that follows the writer above is unaffected.

Readers first, gated by the card. Every build before J1659 refuses payload type 33 as unknown, which files and shows nothing, so a sender sends an update only to a paired contact whose card advertises the capability upd.

5.23a Update media, sealed once (J1674)

A video or voice Update 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 Update header (§5.23), 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 header's media object. An Update header (§5.23) that carries media adds

"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). A video Update also carries its first frame as the JPEG of §5.23 ("img":true), so it has a picture before and without the download.

Opening. A reader fetches the blob from the relay address and refuses it unless:

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, because an Update lives 24. 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.

TypeBody (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

#### 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 phonethe MLS group state (RFC 9420): the ratchet tree, the epoch secrets, the group context
the relayper 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
nobodythe 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:

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 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 otherwise deniable. 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:

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

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

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

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

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:

keynametypebound
---:---------
1urltstrat most 2048 bytes; https only
2titletstrat most 480 bytes (120 characters as the sender cuts it)
3summarytstrOptional. At most 960 bytes
4sitetstrOptional. At most 120 bytes; the page's own site name
5imagebstrOptional. A baseline JPEG, at most 32,768 bytes, at most 600 pixels on its long side

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:

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

bytesignalreceiver
0x00stopped typingclears "typing"
0x01typingshows "typing" for 6 s unless refreshed; also counts as chat-open
0x02chat openthe sender has this chat open in the foreground; valid for 25 s unless refreshed
0x03chat closedthe 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)

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

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 (senderIdentity, key 1), 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.

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

#TypeDirectionWhen sentBoundWhat a relay learns
--:----------------------------------:---------------------
1HELLObothfirst frame of every encounter64 Bprotocol version, nothing else
2CAPABILITYbothimmediately after HELLO128 Btransport class bits, storage class
3PRESENCEbothafter CAPABILITY2 KB [policy]a set of opaque 16-byte tokens
4ROUTE_QUERYbroadcastsender seeks a destination96 Ba query ID, a token, a hop budget
5ROUTE_RESPONSEreverse pathdestination or cache answers96 Bthat some node answered
6INVENTORYbothafter PRESENCE4 KB [policy]fragment keys and destination tokens held
7WANTbothafter INVENTORY4 KB [policy]which packet IDs a peer lacks
8ENVELOPEbothin response to WANT32 KB [policy]§2's fields only
9ACKbothon accepting an ENVELOPE64 Ba packet ID was accepted
10GOODBYEbothend of encounter32 Bthe encounter ended
11PAIR_OFFERinitiatorfirst flight of a pairing (§6.5)96 Ban X25519 public key and a nonce
12PAIR_ACCEPTresponderanswering a PAIR_OFFER160 Bthe same, plus a signature it cannot attribute
13PAIR_CONFIRMinitiatorcompleting a pairing96 Ba signature it cannot attribute
16PAIR_OFFER_V2initiatorfirst flight of a v2 pairing (§6.5.9)96 Ba hash of an X25519 public key, and a nonce
17PAIR_ACCEPT_V2responderanswering a PAIR_OFFER_V2160 Ban X25519 public key, a nonce, a signature it cannot attribute
18PAIR_CONFIRM_V2initiatorcompleting a v2 pairing128 Bthe committed X25519 public key, a signature it cannot attribute
19PRESENCE_CHALLENGEbothon the peer's first PRESENCE, every encounter (§6.2a)64 Ba random nonce
20PRESENCE_PROOFbothanswering the first challenge (§6.2a)640 Bthe 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:

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.

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

LimitDefault [policy]
MAX_PACKETS_PER_ENCOUNTER64
MAX_BYTES_PER_ENCOUNTER1 MB
MAX_RELAY_STORAGE25–100 MB, user-configurable
MAX_ENVELOPE_BYTES32768
MAX_RELAY_LIFETIME144 buckets (24 h)
MAX_ACCEPTED_HOP_LIMIT8
MAX_DISCOVERY_HOPS3

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:

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.

#TypeFromBodyFrame sizeBound
--:-----------------------------:------:
11PAIR_OFFERinitiator{1: ephemeralPublic (32 B), 2: nonce (16 B), 3: generation (uint32)}58–62 B96 B
12PAIR_ACCEPTresponder{1: ephemeralPublic (32 B), 2: nonce (16 B), 3: generation (uint32), 4: signature (64 B)}125–129 B160 B
13PAIR_CONFIRMinitiator{1: signature (64 B)}70 B96 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_OFFERs 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 ifApplies to
--:----------------------------------
1The peer's identity key equals our ownboth, before the exchange begins
2The frame does not decode, or violates §6.5.3's widths and field setevery received frame
3The frame type is not the one this stage expectsevery received frame
4The peer's ephemeralPublic equals our ownPAIR_OFFER, PAIR_ACCEPT
5The peer's nonce equals our ownPAIR_OFFER, PAIR_ACCEPT
6PAIR_ACCEPT.generation is below the generation we offeredPAIR_ACCEPT
7The peer's signature does not verify under the identity key from the pairing codePAIR_ACCEPT, PAIR_CONFIRM
8X25519(myEphemeralPriv, theirEphemeralPub) is non-contributory (all-zero)whichever frame carried the peer's ephemeral
9The 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:

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:

Does not provide:

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

#### 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):

#TypeFromBodyBound
--:------------------------:
16PAIR_OFFER_V2initiator{1: commitment (32 B), 2: nonce (16 B), 3: generation (uint32)}96 B
17PAIR_ACCEPT_V2responder{1: ephemeralPublic (32 B), 2: nonce (16 B), 3: generation (uint32), 4: signature (64 B)}160 B
18PAIR_CONFIRM_V2initiator{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 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.

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

Response plaintext -- {1: version (= 1), 2: [[envelopeId, envelope (bstr), ring (bool)] …], 3: more (bool), 4: padding (bstr)}

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.

#TypeDirectionWhen sentBoundWhat the carrier learns
--:----------------------------------:---------------------
14CARRY_REQUESTattached → carrierthe attached phone has no route and the peer set ROLE_HAS_INTERNET2.2 KBthat this peer wants its mail fetched, and when
15CARRY_RESPONSEcarrier → attachedthe relay answered, or the carrier declines29 KBone 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

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

#### 6.6.7 What each party can see, stated plainly

learnscannot learn
carrierthat 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 samewhich requests were real, the tokens, how many correspondents, who wrote, any envelope, any content
anyone listening to the radiothe same as the carrier: carried frames between phones that have not paired are not encrypted on the link, only inside the sealthe same as the carrier
relayone token and its ids per request, as for any collect, and that the request was carriedanything inside an envelope; the attached phone's address
the network the carrier is onthat the carrier talked to our relay, and how oftenanything 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_pairing_recipient_derive, 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:

  1. The core picks the agreement, from the invitation's bytes (meshcore_pairing_recipient_derive): 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).
  2. 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.
  3. 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.
  4. A v1 to v3 invitation is unproven, as today.
  5. Only a proven invitation may be accepted without the person being asked (step 3, automatic link repair), and only when all of these hold:
  6. its identity is, byte for byte, a contact's key. Never a match by name or handle; the name travels unsigned.
  7. 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.
  8. 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.
  9. 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

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) takes the prekey's SECRET and derives both public halves itself, so it cannot be used as a general signing oracle. 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 -- only when the app passes its PRUNES_SUPERSEDED_PREKEYS switch, which is OFF in 3.013 (J2895c): deleting a secret cannot be undone, and the review of 16 Oct 2026 found cases the 30-day keep does not cover. - 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 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.

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

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.

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

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:

AODVHereWhy
Destination sequence numbers prevent stale routesShort route lifetimes (15 s) insteadSequence numbers are a stable per-destination identifier — precisely the linkable value this protocol exists to avoid
Nodes keep persistent routing tablesReverse-path state only, 10 sPersistent tables are a topology map (SPEC-REVISION §1.5)
Route replies may be sent by intermediate nodes with a fresh-enough routeOnly the destination may originate a replyAn intermediate node cannot compute the MAC, so it cannot prove the destination exists
Local repair patches a broken linkFull rediscoveryLocal 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 queryIDs 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

TimerDefault [policy]Purpose
QUERY_STATE_TTL10 sReverse-path entry lifetime (the owner's sketch)
ROUTE_STATE_TTL15 sHeld-route lifetime (the owner's sketch)
SEEN_QUERY_TTL60 sDuplicate suppression window
RING_BASE700 msExpanding-ring timeout base
PRESENCE_CACHE_TTL900 sCached presence entry lifetime
ADVERT_ROTATE900 sEphemeral 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:

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.

ConditionResult
Probe in foreground, Android filtering by our 128-bit service UUIDFOUND. Exactly one device, −38 dBm, 30 ms interval
Probe backgrounded, same UUID filterNOT FOUND. The UUID leaves the standard fields, as documented
Probe backgrounded, Android filtering Apple overflow 4C 00 01Device 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.

ResultBasis
Foreground iPhone → Androidworksmeasured
Backgrounded iPhone → discovered by Androidfailsmeasured, two UUIDs
Backgrounded iPhone → discovers othersworksmeasured
Android advertising persistentlyworksmeasured (foreground service)
Backgrounded iPhone → discovered by iOSuntestedApple 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:

QuestionStatus
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 backgroundedUntested. 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 directionUntested

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:

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

TierSituationAvailable
1No internet at allLocal radio only — proximity and courier
2Internet present but restrictedLocal transports, plus whatever internet actually works
3Full internetEverything, 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.

TermDefinitionDefault [policy]
hopPenaltyHOP_COST × hopCountHOP_COST = 20
weakLinkPenalty255 − min(LinkQuality) over every hop—
transportPenaltysummed per hop by classWi-Fi Aware 0 · LAN 10 · Wi-Fi Direct 20 · BLE 40 · gateway 120
congestionPenalty0–100, from each relay's queue depth0
batteryPenalty0–60, from each relay's own power state0

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.

ConstantDefaultNotes
------:---
EPOCH_SECONDS600Token epoch; ±1 accepted, so 30 min useful life (late mail: §4.6)
ADVERT_ROTATE900 sEphemeral advertisement ID rotation
PRESENCE_CACHE_TTL900 sCached presence entry lifetime
QUERY_STATE_TTL10 sReverse-path entry
ROUTE_STATE_TTL15 sHeld route
SEEN_QUERY_TTL60 sDuplicate suppression window
RING_BASE700 msExpanding-ring timeout base
MAX_DISCOVERY_HOPS3Discovery scope
MAX_ACCEPTED_HOP_LIMIT8Envelope relay ceiling, enforced locally
MAX_ENVELOPE_BYTES32768Checked before allocation
MAX_FRAGMENTS16Checked before reassembly
MAX_DECOMPRESSED32768Enforced during streaming decompression
PADDING_BUCKETS256 / 1K / 4K / 16K / 32K / 62,976 / 65,024Mandatory
MAX_RELAY_LIFETIME144 buckets (24 h)Relay retention only
Sender retentionindefiniteOwn outbox; not a relay limit
MAX_RELAY_STORAGE25–100 MBUser-configurable
MAX_PACKETS_PER_ENCOUNTER64
MAX_BYTES_PER_ENCOUNTER1 MB
MAX_INVENTORY_ENTRIES128Entries one INVENTORY lists; the wire bound
MAX_QUEUED_ENVELOPES512Queue a node holds (J2657); own mail is half of it
MAX_GROUP_MEMBERS24Enforced; fan-out cost is N× (§5.2). Raised from 6 by J902
PRESENCE_SET_SIZE32Fixed; padded or sampled so contact count never leaks
BEACON_ADVERT_INTERVAL1000 msPersistent-node continuous advertising (§7.8)
HOP_COST20Route cost
RSSI_SAMPLES4Averaging 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."

English is the reference text; any translation is a convenience.