# Channel Messenger — complete documentation
Generated from the repository. One file, for reading end to end.


---

<!-- README.md -->

# Overview

# ChannelMessenger

A privacy-preserving opportunistic MANET with delay-tolerant fallback — secure text
messaging with no Internet, no cellular, no infrastructure Wi-Fi, and no server.

**Not a chat app that happens to work offline.** A message travels A→B, or A→relay→relay→B,
or waits in someone's pocket until they walk past the recipient. Relays learn essentially
nothing, and there is no company in the path — not by policy, but because the infrastructure
does not exist.

## Status

| | |
|---|---|
| **Assignment 01 — wire protocol** | ✅ complete |
| **Assignment 02 — `mesh-core-rs` + simulation** | ✅ acceptance set passing |
| Tests | **79**, all green, `clippy -D warnings` clean |
| Radio transports | not started — deliberately |
| Apps | not started — deliberately |

## Read in this order

1. **`CLAUDE.md`** — start here if you are an agent picking this up cold.
2. **`DECISIONS.md`** — every settled decision with the reasoning. **Wins over both specs.**
3. **`PROTOCOL.md`** — the normative wire protocol, §0–§9.
4. **`THREAT-WALKTHROUGH.md`** — what a relay actually learns, including where the honest
   answer is "more than we would like".
5. `SPEC-REVISION.md`, then `SPEC-ORIGINAL.md` — Don's source material, verbatim.
   The revision supersedes the original wherever they conflict.
6. **`LEGAL-RISK.md`** — what can be demanded, what we could produce, and the features that would
   quietly undo it. Read §9 before adding anything touching identity, storage, keys or telemetry.
7. `EXPORT-COMPLIANCE.md` — US encryption export position, checked against primary sources.

`SPEC.md` is a derived distillation and is **lossy**; prefer the two source files.

## Build

Rust is not on the default PATH on Studio (Homebrew's rustup uses its own prefix):

```
export PATH="/opt/homebrew/opt/rustup/bin:$PATH"
cd mesh-core-rs
cargo test
cargo clippy --all-targets -- -D warnings
```

## Why the protocol came before the code

Four platform implementations — iOS, Android, macOS, Windows — drift apart unless something
pins them. `vectors/` is that something: fixed inputs to expected bytes, generated by
`vectors/generate.py` and never transcribed by hand. A second implementation can prove
conformance without a radio.

Two examples of that paying off, both real:

- The worked AAD example in `PROTOCOL.md` §3.3 was hand-written and wrong twice. Computing
  it caught both errors.
- `vectors/04-route-cost.json` does not merely check the weakest-link rule, it **discriminates**:
  an implementation that picks the strongest first hop chooses the wrong route and fails.

## What this deliberately does not do

No server, no accounts, no telemetry, no address-book access, no remote push, no moderation
capability, and no claim of anonymity against a global observer. Each of these is a decision
with reasoning in `DECISIONS.md`, not an omission.


---

<!-- SPEC.md -->

# Specification

# ChannelMessenger — Architecture & Implementation Specification

**Status:** authoritative. Supersedes the Swift-first framing of the original §40 assignment.
**Owner:** Don Elton. Written from his spec of 2026-09-01 plus his same-day revision.

---

## 0. What this is

A privacy-preserving **opportunistic MANET (mobile ad hoc network) with delay-tolerant fallback** —
not a chat app that happens to work offline.

Secure text messaging with **no Internet, no cellular, no infrastructure Wi-Fi, and no central server**.
A message may travel A→B, or A→relay→relay→B. **Relays learn essentially nothing.**

Don's framing, verbatim and load-bearing:

> "The next thing I would design before Claude writes production radio code is the exact wire protocol
> and route-discovery state machine, down to each packet type and field. That will prevent iOS, Android,
> macOS, and Windows implementations from gradually becoming incompatible."

**So the first deliverable is a wire protocol document, not code.**

---

## 1. THE REVISION — read this before the original spec

Don revised the architecture after the original spec was written. Where they conflict, **this section wins**.

### 1.1 Platform-neutral from day one
Android and possibly Windows are targets. The mesh protocol must be platform-neutral immediately.
Apple Wi-Fi Aware, Android Wi-Fi Aware / Wi-Fi Direct, BLE, Windows Wi-Fi Direct, LAN and Internet all
become **interchangeable transport adapters**.

### 1.2 Shared core in Rust — NOT parallel Swift and Kotlin implementations
> "I would not have Claude Code independently implement the cryptographic protocol and routing algorithm
> once in Swift and once in Kotlin. That invites subtle protocol divergence."

`mesh-core-rs` owns: crypto, envelopes, identities, route discovery, routing, duplicate suppression,
serialization, receipts, protocol state machine.

Compiled for iOS, macOS, Android, Windows. Bindings via **UniFFI** for Swift/Kotlin; C ABI or generated
bindings for Windows.

Platform apps own **only**: radio APIs, UI, lifecycle, notifications, permissions, secure key storage.

### 1.3 Wire format: canonical CBOR, not Swift Codable
Explicitly defined canonical serialization. Deterministic encoding rules must be written down, because
two implementations that disagree on map key ordering will silently fail authentication.

### 1.4 Routing: bounded route discovery FIRST, flooding only as fallback
Supersedes "controlled epidemic routing" in the original §16.

1. Direct discovery
2. Bounded route discovery, **default max 3 hops**
3. If a route is found, send by the best route
4. If no route exists, fall back to store-and-forward **courier mode**

Two distinct behaviours: **Live mesh** (Carol reachable now, message traverses immediately) and
**Courier mode** (Bob stores the packet, walks near Carol twenty minutes later, delivers).

### 1.5 Route discovery must not leak topology
Relays keep **short-lived opaque reverse-path state only**:

```
query AB12 · receivedFrom = PeerBob · hop = 2 · expires = now + 10s
token 7C91 reachable through peer E32F · cost 137 · expires 15s
```

They must NOT maintain global tables of the form "I can reach Alice / Bob / Carol". That leaks topology.

### 1.6 Presence beacons build a privacy-preserving routing table
Carol periodically emits `PresenceToken = HMAC(contactSecret, currentEpoch)`.
Alice knows Carol's expected token. **Bob does not.** But Bob can cache
`opaque-token-7C91 heard from radio-peer-X, 3s ago, quality 213` and answer or forward Alice's query fast.

### 1.7 Token rotation is mandatory
Otherwise an observer in an airport follows one unknown person across locations.
`token = HMAC(pairwiseRoutingSecret, floor(unixTime / 600))` — 10 minute epochs.
Receiver accepts **previous, current and next** epoch to tolerate clock skew.
Consecutive tokens must have no observable cryptographic relationship.

### 1.8 Link quality is an abstraction, and the cost is the WEAKEST link
Do **not** pick the strongest first hop. Don's example:

```
Route A:  Alice →(-42) Bob →(-88) Carol
Route B:  Alice →(-61) Dave →(-59) Carol      ← Route B should win
```

Define a generic **LinkQuality 0–255**. Each transport maps its own metric into it: BLE from RSSI,
Wi-Fi Aware from link metrics, LAN from latency/loss, Internet from latency/reachability.

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

### 1.9 Wi-Fi Aware is the preferred interoperable transport
It is a **Wi-Fi Alliance standard**, not an Apple-only mechanism — Android supports it natively on capable
devices. BLE is the universal denominator. Windows Wi-Fi Direct is a platform-specific adapter, and
Microsoft's older Wi-Fi Direct **Services** API is deprecated — do not depend on that service layer.

### 1.10 Layer diagram (Don's)
```
Messaging UI → Conversation Layer → Crypto Layer → Mesh Protocol
   → Routing Engine → Transport Manager
        ├── BLE   ├── Wi-Fi Aware   ├── Wi-Fi Direct   ├── LAN   └── Internet
```

---

## 2. Non-negotiables carried from the original spec

- **Envelopes, not sessions.** Do not design around TCP sessions between users. The encrypted Envelope is
  byte-identical regardless of the path it travels.
- **Identity is cryptographic and unrelated to the device.** Curve25519 signing + key agreement, generated
  locally, private keys in Keychain / platform secure storage. NEVER derive identity from phone number,
  email, Apple ID, serial, MAC, advertising ID, vendor ID, hostname or device name.
- **Routing identity ≠ human identity.** No permanent user IDs or public identity keys in relay packets.
  `routingSecret = HKDF(sharedSecret, "routing-v1")`, rotating destination tokens.
- **Sealed-sender property.** Sender info may live in the encrypted payload; never in the outer Envelope.
- **No device/OS advertising.** Never advertise "John's iPhone". Capabilities as protocol bits
  (`supportsTransportClass3`), never `deviceType = iPhone17`.
- **Pairing via QR first**, with a short authentication string (e.g. `742 983 125`) both sides compare.
  NFC is an identity/pairing mechanism only — **never a messaging transport** (Core NFC is for tags).
  AirDrop and cloud accounts are NOT trust mechanisms.
- **Standard crypto only.** HKDF derivation, ChaChaPoly or AES-GCM, unique nonce per message, protocol
  metadata as AAD. Double Ratchet after the basic system works. Never invent cryptography.
- **Abuse limits:** text only, ~32 KB max Envelope, ~24 h max relay lifetime, ~8 max hops, 25–100 MB
  configurable relay storage, per-encounter rate limits. Discard structurally/cryptographically invalid
  packets immediately.
- **Relay policy:** Relay Off / Contacts Only / Community Mesh.
- **Message states:** Queued, Nearby route available, Transferred, Relaying, Delivered, Read, Expired.
  Staying Queued for a long time is correct behaviour, not a bug.
- **Ordering:** monotonic per-sender sequence number **inside the encrypted payload**. Do not trust clocks.
- **Hostile input:** every parser treats network data as hostile. Strict length bounds before allocation.
  Never force-unwrap or `try!` network/crypto input.
- **Background reality:** iOS will not run an unrestricted permanent mesh. Promise *opportunistic delivery*,
  not a continuously running invisible router. Design every subsystem to be interrupted and resumed.
  Use an explicit foreground **Mesh Active / Flight Mesh** session model.

## 3. Honest claims

Claim: the protocol discloses **no device model, OS, telephone number, account identifier, or permanent
device identifier**.

Do NOT claim: complete metadata anonymity, or that an observer cannot tell a transmitter is an iPhone.
Radio fingerprinting and Apple's underlying P2P protocol behaviour may reveal platform information.
This is application-layer minimisation.

## 4. Threat model

Protect against: relays reading or modifying messages, observers reading plaintext, replay, forgery,
server compromise, server operators reading contents, permanent identifiers in discovery, stolen database
files revealing keys, duplicate packet injection.

Do NOT yet claim protection against: a compromised destination device, malware in decrypted memory,
global radio traffic analysis, OS compromise, physical extraction from an unlocked device, or perfect
sender/recipient anonymity against a global observer.

## 5. Acceptance test (Don's revised, stronger version)

```
iPhone A   Android B   iPhone C   Android D
A↔B  B↔C  C↔D     A cannot reach C or D.  B cannot reach D.
```
1. A issues route discovery for D's token, max 3 hops.
2. Discovery finds A→B→C→D. D returns a route response along the reverse path.
3. A sends the encrypted message; B and C relay.
4. **Neither B nor C can determine** sender identity, recipient identity, plaintext, conversation,
   device model, or OS.
5. D decrypts and returns an authenticated delivery receipt.
6. Break C↔D and prove routing **invalidates or expires** rather than retrying a stale path forever.
7. Enable courier mode: C retains the packet, reconnect C↔D later, verify delayed delivery.

No Internet or cellular at any point.

## 6. Open questions for Don — do not guess

1. **Repo/BOM:** private GitHub repo, or local-only? (affects how machines share it)
2. **Windows in scope for v1**, or design-for-but-defer?
3. **Rust core confirmed** for the MVP, accepting the toolchain cost (cargo-ndk, UniFFI, xcframework),
   or Swift-first for Stage 1 and port to Rust before Android?
4. Apple Developer / Play Console identifiers to use.
5. **What the pairing code carries.** `SPEC-ORIGINAL` §10 says the QR payload holds "key agreement
   public information" and a "pairing nonce"; `DECISIONS.md` E3 says the invite blob "carries public
   keys and a nonce". `PROTOCOL.md` §6.5 (Handshake v1, written 2 September 2026) requires each side's
   X25519 key and nonce half to be **fresh per pairing attempt**, which a value printed into a QR or
   left in a mailbox cannot be. What shipped — `PairingCode` on both platforms — carries the identity
   key alone, which is consistent with §6.5. `DECISIONS.md` outranks `PROTOCOL.md`, so E3's wording
   needs either an amendment or a note saying its "public keys" meant identity keys only. **Nothing is
   broken today**; this is a documentation conflict that will mislead the next reader of E3.

## 7. Verification note

This spec cites platform capabilities (Apple Wi-Fi Aware on iPhone 12+, Android Wi-Fi Aware and Wi-Fi
Direct, deprecated Windows Wi-Fi Direct Services). Don researched these. The implementing agent must
**verify current API surface against live Apple/Android/Microsoft documentation before writing radio code**
— do not treat any API detail in this document, or any model's training knowledge, as current.


---

<!-- PROTOCOL.md -->

# Protocol

# PROTOCOL.md — ChannelMessenger wire protocol, normative

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

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

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

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

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

---

## 0. Conventions

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

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

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

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

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

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

---

## 1. Canonical CBOR

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

### 1.1 Encoding rules — normative

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

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

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

### 1.2 Unknown fields

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

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

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

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

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

---

## 2. Envelope v1

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

### 2.1 Fields

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

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

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

### 2.2 Field definitions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### 2.3 Size bound

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

---

## 3. AAD construction

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

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

### 3.1 Layout — exactly 47 bytes

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

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

**Excluded and why:**

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

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

### 3.2 Fragmented messages have exactly one AAD

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

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

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

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

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

### 3.3 Construction is independent of parsing

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

### 3.4 Worked example

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

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

produces the AAD:

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

= `010102030405060708090a0b0c0d0e0f10a1a2a3a4a5a6a7a8a9aaabacadaeafb0002cc6e090000110000100000110`

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

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

---

---

## 4. Identity, secrets and token derivation

### 4.1 Primitives — CryptoSuite v1

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

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

### 4.2 Pairing output

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

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

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

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

### 4.3 Routing secret

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

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

### 4.4 Presence and destination tokens

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

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

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

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

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

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

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

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

### 4.5 Message key

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

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

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

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

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

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

### 4.6 Epoch acceptance and clock skew

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

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

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

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

### 4.7 Service identifier

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

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

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

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

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

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

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

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

---

## 5. Payload structure, padding and compression

### 5.1 Plaintext layout

The plaintext encrypted into `ciphertext` is:

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

`PayloadMap` keys:

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

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

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

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

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

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

### 5.2 Groups — pairwise fan-out

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

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

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

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

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

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

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

### 5.3 Padding — mandatory

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

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

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

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

### 5.4 Compression — optional, inside the padding

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

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

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

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

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

### 5.5 Policy piggybacking

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

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

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

Reconciliation rules:

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

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

---

## 6. Packet types

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

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

### 6.1 Summary

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

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

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

### 6.2 Encounter sequence

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

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

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

### 6.3 Definitions

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

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

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

Suppressed when: the peer's HELLO failed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### 6.4 Abuse limits

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

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

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

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

### 6.5 Handshake v1 — the pairing key agreement

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

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

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

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

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

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

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

#### 6.5.2 Roles

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

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

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

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

#### 6.5.3 Message formats

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

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

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

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

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

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

#### 6.5.4 The transcript

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

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

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

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

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

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

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

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

#### 6.5.5 The exchange

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

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

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

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

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

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

#### 6.5.6 Checks and abort conditions — normative

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Provides:**

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

**Does not provide:**

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

---

## 7. Route discovery — RoutingProtocol v1

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

### 7.1 Ancestry

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

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

**Deliberate deviations from AODV:**

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

### 7.2 Originator state machine

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

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

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

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

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

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

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

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

### 7.3 Relay behaviour

On `ROUTE_QUERY`:

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

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

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

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

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

### 7.4 Destination behaviour

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

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

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

### 7.5 Timers

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

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

### 7.6 Route breakage

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

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

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

### 7.7 Courier mode

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

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

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

### 7.8 Persistent nodes and wake beacons

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

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

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

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

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

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

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

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

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

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

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

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

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

```
0100000000080000000000000000000004
```

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

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

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

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

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

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

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

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

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

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

#### CONCLUSIONS: what the radio actually permits

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

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

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

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

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

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

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

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

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

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

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

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

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

#### The design consequence

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

#### Force-quit is permanent

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

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

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

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

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

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

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

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

#### Local Push Connectivity is unavailable to us

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

*Evidence: strong — Apple technote TN3134.*

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

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

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

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

---

## 7.10 Connectivity tiers

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

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

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

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

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

---

## 8. LinkQuality and RouteCost

### 8.1 LinkQuality — the transport contract

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

**BLE — normative mapping from RSSI:**

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

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

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

### 8.2 The cost formula

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

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

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

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

### 8.3 Weakest link, not strongest first hop

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

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

### 8.4 Accumulation along the reverse path

The destination initialises:

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

Each node forwarding a `ROUTE_RESPONSE` then:

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

and the originator finally computes:

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

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

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

---

## 9. Policy table

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

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

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

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

---

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




---

<!-- THREAT-WALKTHROUGH.md -->

# Threat walkthrough

# THREAT-WALKTHROUGH.md — what a relay actually learns

Assignment 01, deliverable 3. Per packet type, precisely what a passive relay observes.

**The instruction this document is written under**, from `ASSIGNMENT-01.md`:

> Show that a relay observing ROUTE_QUERY + ROUTE_RESPONSE + ENVELOPE cannot identify sender,
> recipient, or conversation. **If it *can* learn something, say so plainly rather than asserting
> privacy.**

It can. §5 and §6 say so plainly. Assertions of privacy that do not survive an adversary who is
paying attention are worse than useless, because people rely on them.

---

## 1. Adversary positions

These are genuinely different, and conflating them is how privacy claims become false.

| Position | Description |
|---|---|
| **P1 · First hop** | The relay a sender hands a packet to directly |
| **P2 · Middle relay** | A relay with another relay on both sides |
| **P3 · Final hop** | The relay that hands a packet to its destination |
| **P4 · Passive listener** | In radio range, relaying nothing |
| **P5 · Global observer** | Sees many locations at once — **out of scope** (`SPEC-ORIGINAL` §32) |

**With `MAX_DISCOVERY_HOPS = 3`, P2 frequently does not exist.** On a two-hop route A→B→C there is
exactly one relay, and it is simultaneously P1 and P3. §6 is about what that means.

---

## 2. What every relay learns unconditionally

Regardless of packet type, from the fact of the encounter itself:

- A device running this protocol is physically nearby, now.
- Its ephemeral advertisement ID — rotating every 15 minutes, derived from nothing.
- Approximate distance, from radio signal strength.
- Packet sizes, to bucket granularity, and arrival times.
- Whatever the radio layer discloses beneath us — MAC-layer behaviour, chipset fingerprints, timing
  characteristics. **We do not control this and do not claim to** (`SPEC-ORIGINAL` §3).

---

## 3. Per packet type

| Packet | What a relay learns | What it does **not** |
|---|---|---|
| `HELLO` | A protocol version | Anything about the device, user or contacts |
| `CAPABILITY` | Transport-class bits, storage class, persistent/gateway role | Platform, model, OS, build. Capabilities are protocol bits, never `deviceType = iPhone17` (`SPEC-ORIGINAL` §15) |
| `PRESENCE` | Exactly 32 opaque 16-byte values | Who they belong to; **and not the contact count**, since the set is fixed-size and padded (§7) |
| `ROUTE_QUERY` | A random query ID, one opaque token, a hop budget ≤ 3 | Who is asking, who is sought, or whether either is a contact of the relay |
| `ROUTE_RESPONSE` | That *someone* holding the right secret answered; a link-quality figure; a hop count | The path, the responder's identity, or any node between |
| `INVENTORY` | Which packet IDs a peer holds, and their destination tokens | Who those packets are for. Destination tokens are derived under a different label from presence tokens, so they cannot be matched against any beacon (`PROTOCOL.md` §4.4) |
| `WANT` | Which packet IDs a peer lacks | Why, or for whom |
| `ENVELOPE` | Only §2's fields — see §4 below | Content, sender, recipient, conversation, or any permanent identifier |
| `ACK` | That a relay took custody of a packet ID | That anyone received or read anything. Delivery receipts are end-to-end encrypted envelopes of their own |
| `GOODBYE` | The encounter ended, and loosely why | Anything else |

---

## 4. The Envelope, field by field

| Field | Visible to a relay | Assessment |
|---|---|---|
| `protocolVersion` | Yes | Version only |
| `packetID` | Yes | Random, not derived. **Constant along the journey** — that is what makes duplicate suppression work, and it means P5 could trace one packet across the network. Accepted; P5 is out of scope |
| `destinationToken` | Yes | Opaque, rotates every 10 minutes, unlinkable across epochs, and cryptographically unrelated to any presence beacon |
| `creationBucket` | Yes | Age of the packet, to 10-minute granularity |
| `expiresAfter` | Yes | Intended lifetime. **A minor leak we accept:** an unusually short lifetime may signal something about the sender's intent |
| `transitPolicy` | Yes | One bit. Partitions traffic into two classes — mitigated by defaulting to restrictive, so the large population carries the restrictive value (`DECISIONS.md` D5) |
| `payloadLength` | Yes | One of five buckets. Mandatory padding means it does not reveal actual length |
| `fragment*`, `totalLength` | Yes | That a message exceeded a bucket, and by roughly how much |
| `nonce` | Yes | Random |
| `hopLimit` | Yes | Remaining allowance, so roughly how far it has already travelled |
| `ciphertext` | Yes, as bytes | Nothing without the key |

No display name, device name, OS, model, build, phone number, account identifier, or permanent device
identifier appears anywhere in it (`SPEC-ORIGINAL` §13, §15).

---

## 5. The combined observation — the assignment's actual question

A relay sees, in sequence:

```
1.  ROUTE_QUERY    from peer P    { queryID Q, presenceToken X,  budget 3 }
2.  ROUTE_RESPONSE toward peer P  { queryID Q, quality, hops 2 }
3.  ENVELOPE       from peer P    { packetID N, destinationToken Y }
```

**Cryptographically, X and Y are unlinkable.** They are HMACs under different labels, and no party
without `routingSecret` can connect them. The design does what it was meant to do at the level of
values.

**Temporally, they link trivially.** All three arrive from the same immediate peer within seconds.
A relay does not need to break any cryptography to conclude: *the envelope P just handed me is for
whoever P was looking for.*

**So state it plainly. A first-hop relay learns:**

- that its peer P is trying to reach the holder of token X;
- that P then handed it a message for that same party;
- that a **communication relationship exists between two pseudonyms**, right now.

**What it still does not learn:**

- Who P is. P is an ephemeral radio identifier that rotates every 15 minutes and is derived from
  nothing — no identity key, no account, no device identifier.
- Who the recipient is. The token rotates every 10 minutes with no cryptographic relationship between
  consecutive values.
- The content, the conversation, or whether these two have ever spoken before.
- **Anything that survives the epoch.** Both the pseudonym and the token roll independently. Linking
  today's pair to tomorrow's is not possible from this observation alone.

**The honest summary:** the protocol conceals *identity*; within a single epoch, adjacency to a
sender still reveals *the existence of a correspondence*. Sealed sender protects who, not that.

---

## 6. Two-hop routes: the single relay sees both ends

On `A → B → C`, B is P1 and P3 at once. So B learns:

- the sender's radio pseudonym,
- the recipient's radio pseudonym — because B must physically hand the packet to C,
- that these two are corresponding, within this epoch.

**This is not fixable within this architecture and we do not claim otherwise.** Someone must deliver
the packet to the destination, and that someone necessarily knows which radio peer received it. Onion
routing would address it, at the cost of becoming an anonymity network rather than a messenger —
explicitly out of scope for v1 (`DECISIONS.md` D7).

**Context that matters for the flagship scenario:** on an aircraft, B is a passenger seated within a
few metres of A and C and can simply *look at them*. Radio-layer correlation is not the binding
privacy constraint in the environment this was designed for. It matters much more in a dense public
space among strangers, and that is the case to be honest about.

---

## 7. A leak found while writing this, and fixed

Checkpoint 2 moved presence tokens off the broadcast channel into a post-connection `PRESENCE` packet,
precisely so the *number* of tokens could not fingerprint a user (`PROTOCOL.md` §4.6).

**The leak came straight back on the unicast channel.** Presence tokens are pairwise, so a `PRESENCE`
list of length N discloses that the sender has N contacts — to anyone who connects, including a
stranger in a queue.

`PROTOCOL.md` §6.3 now requires the list to be **exactly `PRESENCE_SET_SIZE` (32) entries always**,
padded with CSPRNG values indistinguishable from real tokens, or randomly sampled and re-sampled each
encounter when a node has more. Contact count is no longer observable.

Recorded rather than quietly patched, because it is a useful demonstration that the fingerprint moved
when the channel moved, and the same mistake is available anywhere else a list length depends on a
user's contacts.

---

## 8. Leaks we accept, and why

| Leak | Why accepted |
|---|---|
| `packetID` traceable end to end | Required for duplicate suppression. Only exploitable by P5, which is out of scope |
| Correspondence visible to an adjacent relay (§5) | Inherent to relaying; mitigated by rotating pseudonyms and 10-minute tokens |
| Both endpoints visible on a two-hop route (§6) | Inherent; onion routing is the only fix and it is out of scope |
| Message size to bucket granularity | Padding buckets are a deliberate trade against bandwidth |
| **Fragment injection destroys a message** | A fragmented message has one AAD (`PROTOCOL.md` §3.2), so per-fragment index and length are unauthenticated. Corrupting reassembly makes the tag fail. It is a denial of service, never a break — and it grants nothing, since anyone able to inject fragments can already just drop them, which is simpler and equally effective |
| Hop count to the sender | Needed for route selection; discloses coarse proximity of a contact to that contact only (`DECISIONS.md` D3) |
| Packet age and intended lifetime | Needed for expiry enforcement by nodes that cannot read the packet |
| Radio-layer fingerprinting | Outside application control entirely (`SPEC-ORIGINAL` §3) |
| **A persistent node discloses that a node lives at that location** | Inherent to fixed infrastructure. Rotating the advertisement ID does not conceal a device that is always present in one place from an observer who is also in one place. One reason the persistent node is optional, and a reason not to run one somewhere the household's presence is itself sensitive |
| Install base, if broadcast push is ever enabled | Push is off by default and targeted push is a permanent non-goal (`DECISIONS.md` B1) |

---

## 9. Structural properties that do hold

- **No content, ever.** Relays hold ciphertext and no key material.
- **No mailbox role requires decryption.** A persistent node stores envelopes it cannot read
  (`DECISIONS.md` A3).
- **No originator attribution is possible.** No field carries it and no party is positioned to derive
  it. A traceability mandate cannot be satisfied by a system that never had the data — stated here so
  it is on record as a design property rather than an accident.
- **No topology tables.** Only reverse-path state (10 s) and route state (15 s), keyed by opaque
  values.
- **No key escrow, and nowhere to add one.** No server, no server-delivered key material, no lever.
- **Deleting a contact destroys the pairwise secret**, after which anything still in flight to them is
  permanently undecryptable — including by us.
- **Tampering fails closed.** Any structural or cryptographic failure is a silent discard;
  implementations MUST NOT return distinguishing errors, which would build an oracle.

---

## 10. What we do not claim

Carried from `SPEC-ORIGINAL` §32 and `SPEC-REVISION` §3, plus what this walkthrough added:

- Not protected against a **compromised destination device**, malware in decrypted memory, OS
  compromise, or physical access to an unlocked device.
- Not protected against a **global observer** correlating radio traffic across locations.
- **Not** perfect sender/recipient anonymity — §5 and §6 are the specific limits.
- **Not** concealment that a transmitting device is an iPhone. Radio fingerprinting and the
  underlying peer-to-peer protocols may reveal platform. This is application-layer minimisation.
- **Not** protection against a **targeted app-store build**. No protocol design prevents it; the
  partial answer is reproducible builds with published hashes, which requires publishing source and
  is deferred by `DECISIONS.md` F7.
- **Not** enforcement of no-forward or no-screenshot. Those are stated intent, not controls
  (`DECISIONS.md` E5).
- **Not** a guarantee that `transitPolicy` is honoured. Relays share no key with the sender.

**What we do claim**, and what `SPEC-REVISION` §3 permits: the protocol discloses **no device model,
operating system, telephone number, account identifier, or permanent device identifier** — and no
message content, to anyone but its destination.


---

<!-- NETWORK-METADATA.md -->

# Network metadata

# Hiding who connects, and what they connect for

*3 September 2026. Response to: "what else can the server do to hide who connects to send vs
receive — i.e. other relays or vpn nodes for incoming perhaps or something similar making camping
on netlify traffic to track just this app a bit more complex."*

## What is already done, so it isn't redone

Checked in the code rather than recalled:

- **Message plaintext is padded to buckets before sealing** — `[256, 1024, 4096, 16384, 32768]`,
  mandatory rather than a setting, with a strict all-zero check on decode so padding cannot become
  a covert channel (`mesh-core-rs/src/payload.rs`, `PROTOCOL.md` §5.1/5.3). Message *length* is
  already coarse.
- **Server responses are padded to buckets** — `[512, 2048, 8192, …]`, top bucket covering the
  worst case (`server/netlify/functions/_shared.mts`, `DECISIONS.md` J12).
- **Every failure returns one shape**, so there is no "does this mailbox exist" oracle.
- **Destination tokens rotate** every ten minutes, and the server is configured not to log IPs.

## What is still visible to somebody camping on the traffic

**1. The hostname, in the clear, on every single connection.** This is the biggest one by a wide
margin. TLS still sends the server name unencrypted, so a passive observer reads
`channelmessenger.netlify.app` and knows this device runs Channel — before any of the padding above
matters. Everything else on this page is a refinement of a signal that this one gives away for
free. No HTTPS/SVCB record with an ECH configuration is published for the host today.

**2. Send is distinguishable from receive, which is the specific question asked.** Not from the URL
— that is inside TLS — but from *direction asymmetry*:

| | Upstream | Downstream |
|---|---|---|
| Deposit (send) | envelope, ≥256 B bucketed, plus headers | small, 512 |
| Collect, no mail | headers only | 512 |
| Collect, mail waiting | headers only | 2048+ |

So an observer separates senders from receivers by upstream size, and mail from no-mail by
downstream bucket. Neither requires decrypting anything.

**3. Bucket boundaries still leak magnitude**, per `J57` — not how many messages, but roughly how
much.

## What can be done, ranked by value per unit of work

### 1. Encrypted Client Hello — removes the "which app" signal entirely
ECH encrypts the server name in the TLS handshake, so the observer sees a connection to a large
shared CDN and nothing else. This is the highest-value change available because it defeats the
signal that makes all the others findable in the first place.

It is not ours to switch on unilaterally: it needs the CDN to support ECH and the client to fetch
the config over DoH. **Verify Netlify's support before planning on it** — and note the convergence
with `ATTACHMENTS-AND-COST.md`, which already argues for moving storage to Cloudflare. If the
platform moves, ECH becomes available with it.

### 2. Oblivious HTTP — this is the thing being described, and it is standardised
RFC 9458. Requests go to a **Relay**, which sees the client's IP but not the request, and forwards
to a **Gateway**, which sees the request but not the IP. **Neither party alone can link an IP to a
request**, and only collusion between two separate organisations reconstructs it. Apple uses it for
Private Relay and Safe Browsing; Cloudflare and Fastly operate managed relays.

The property this buys is worth stating exactly, because it is stronger than what we claim today:
we currently say we *choose not to log* IP addresses. With OHTTP we would say we **cannot see
them** — a claim that survives a subpoena, a change of hosting provider, and a change of heart.

Our traffic is a good fit: small, stateless, frequent requests. Polling through OHTTP is
unremarkable.

**The load-bearing constraint: the relay must be run by somebody else.** If Channel operates both
halves it is an elaborate way of learning exactly what it learns today.

### 3. Pin request and response to one fixed size — kills the asymmetry in §2
Every interaction becomes the same shape in both directions, so send, receive, and receive-nothing
are indistinguishable. Costs about a cent per user per month (`UNIT-ECONOMICS.md`), free on an
egress-free store, and it is a change to one response path plus the client request builders. Should
land with the cheap-poll change, since they touch the same code.

### 4. Let a user point the app at their own proxy
A SOCKS5/Tor setting is a few hours of work and costs nothing to operate. It serves the small
number of people who genuinely need more than the above, using infrastructure they already trust,
**and we run none of it** — so there is nothing for us to be compelled to hand over.

### 5. Shared CDN addressing is a defensive property worth keeping
The host resolves to shared AWS addresses carrying an enormous number of unrelated sites. Blocking
Channel by IP means breaking a great deal of other traffic, which raises the political cost of
doing so. A dedicated IP would be a convenience for us and a gift to a censor.

## What not to do, and why

**Do not run our own VPN or relay nodes.** It is the intuitive answer and it is backwards: whoever
operates the relay sees the source IPs, so building one moves the observation point *to us*. That
is strictly worse than today — we would be creating the record we currently do not hold, and taking
on network-operator legal exposure to do it. The entire value of OHTTP is that the relay is not us.

**Do not plan around domain fronting.** It worked, and then AWS, Google and Cloudflare all shut it
off in 2018. It is not a tool that is available any more.

**Do not build a bespoke mix network.** `J57`'s anonymity-set argument applies directly: a network
whose only users are Channel users identifies Channel users. Standing inside a large existing crowd
beats building a small new one, which is the whole reason ECH and OHTTP are the right shapes.

## Honest summary

**One change dominates: encrypt the hostname.** Until the SNI is hidden, everything else is
tightening a seal on a box with the label still on the outside. OHTTP is the correct second step
and the one that turns a policy promise into a structural one. Pinning sizes is cheap and answers
the send-versus-receive question directly. Running our own relays would feel like progress and
would be a regression.


---

<!-- ATTACHMENTS-AND-COST.md -->

# Attachments and cost

# Attachments, and what they would actually cost

*3 September 2026. Written in response to: "consider whether we can/should support the camera
and photo sending, videos? … what about other attachments of reasonable size … do the math just
to have an idea for the future."*

## The headline, before the numbers

**The intuition that we cannot afford attachments is correct on Netlify and wrong in general.**
Netlify meters *bandwidth*; every byte a phone collects is billed. Object stores built for this
job meter *operations* and give egress away. On Cloudflare R2 the bytes are close to free and the
cost is the number of requests.

That inverts the conclusion. On R2, **video is cheaper than photos** — not because video is small,
but because people send one video where they send three photos, and requests are what we pay for.

And it exposes the real cost driver, which is neither:

> **Polling costs more than every attachment combined.** At 100,000 users, payload runs about
> $80/month and polling about $1,300/month. Push notifications are not a nicety; they are the
> difference between those two numbers.

## The numbers

Verified 3 Sep 2026 from the vendors' own pricing pages.

**Netlify** (what we run today): bandwidth 20 credits/GB; web requests 2 credits per 10,000;
functions compute 10 credits per GB-hour. A Pro credit is about $0.0067, so **bandwidth is roughly
$0.13/GB**. Blobs storage is not separately published — it draws on the same credit pool.

**Cloudflare R2**: storage $0.015/GB-month, Class A (writes, deletes, lists) $4.50/million,
Class B (reads) $0.36/million, **egress free**. Free monthly: 10 GB, 1M Class A, 10M Class B.

### What a message costs to carry

The server is a relay, not an archive: a message is deleted the moment it is confirmed, so
steady-state storage is only what is *in flight*, not everything ever sent. Bandwidth, by
contrast, is paid twice on every delivered byte — once in, once out.

Sizes used below: voice note (Opus 24 kbps, 30 s) 90 KB; photo at WhatsApp-grade compression
150 KB; photo at good quality 500 KB; 30-second video at WhatsApp-grade 3 MB.

**Monthly cost of payload, by scale** — Netlify bandwidth vs R2 operations:

| Traffic | 1k users | 10k users | 100k users | 100k on R2 |
|---|---|---|---|---|
| Text only (50 msg/day) | $0.40 | $4 | $40 | ~$8 |
| Voice notes (2/day, 90 KB) | $1.44 | $14 | $144 | ~$50 |
| Photos (3/day, 150 KB) | $3.60 | $36 | $360 | **~$77** |
| Photos (3/day, 500 KB) | $12 | $120 | $1,200 | **~$77** |
| Video (1/day, 3 MB) | $24 | $240 | $2,400 | **~$24** |

The R2 column barely moves with size and moves a lot with frequency. That is the whole point:
three photos a day costs three times one video a day, even though the video is twenty times
bigger. **We are not paying for storage. We are paying for requests.**

### And then there is polling

Every background check is a request whether or not a message is waiting. At 100,000 users
checking every 15 minutes, that is 96 checks a day, about **288 million requests a month**:

- On Netlify web requests: roughly **$384/month**, plus function compute on top.
- On R2, if each check is a list operation (Class A): roughly **$1,300/month**.

Against $77/month for the photos those checks are looking for. Polling is 5–17× the cost of the
payload, and it scales with *users*, not with usage — a user who sends nothing at all still costs
us every fifteen minutes, forever.

**This is the strongest argument yet for finishing push.** A wake-up push costs nothing on either
platform (APNs and FCM are free), and it replaces the poll that costs everything. It also happens
to be the better product: messages arrive when they arrive rather than up to fifteen minutes
later. The privacy story does not change — the push carries no content, only "wake up," which is
what `DECISIONS.md` already says it is.

## So: should we ship attachments?

**Photos and voice notes: yes, and the cost is not the reason to hesitate.** At any scale we can
plausibly reach in the next two years, compressed photos on R2 cost less per month than a single
hour of contract work. The reasons to hesitate are engineering, not money.

**Video: technically affordable, but it is the biggest job on the list.** It needs transcoding,
codec choices, thumbnails, playback, and scrubbing — and every one of those is a place to leak
metadata or ship a crash. The cost math says yes; the calendar says much later.

**Other attachments (PDFs, documents): nearly free to carry and cheap to build**, because there is
no rendering problem — hand the file to the OS and let it open. If anything ships after photos,
this is the one with the best ratio of usefulness to work.

### What the engineering actually requires

Not in the protocol — the sealed envelope does not care what is inside it. The work is at the edges:

1. **Raise `MAX_ENVELOPE_BYTES`.** It is 32,768 today (`server/netlify/functions/_shared.mts:20`),
   which is a text-message limit. Attachments need chunking above some threshold rather than one
   enormous envelope, so that a failed transfer resumes instead of restarting.
2. **Strip EXIF before encrypting.** A photo carries GPS coordinates, the camera's serial number,
   and a timestamp. Shipping photo support without stripping these would undo, in one feature,
   every location claim we make. This is not optional and it is not a later refinement.
3. **Thumbnails**, so a conversation does not download megabytes to draw a list.
4. **Progress and resume.** A 3 MB transfer over a bad connection fails often enough that "try
   again from the start" is not acceptable.
5. **The direct path gets *better* here, not worse.** A photo over Wi-Fi Direct is free, fast, and
   never touches a server — the pitch is stronger for photos than it is for text, because the
   saving is visible. Sending a holiday album across a table with no internet at all is a demo
   nobody else can give.

### The migration this implies

If attachments ship, **the mailbox should move to R2 and Netlify should keep the API**. It is not
a hard migration — `deposit`/`collect`/`confirm` are the only functions that touch storage — and
it is worth roughly a factor of ten immediately and much more later. Doing it *before* attachments
is easier than doing it after, because there is less in flight to move.

One caveat worth stating rather than discovering: Cloudflare would then see our traffic patterns.
It never sees plaintext, and it never learns identities, because the tokens are already rotating
and the envelopes are already sealed. But it is a second party in the path, and the privacy policy
would have to say so plainly. That is a real cost, not a bookkeeping one.

## What this does not change

Nothing here argues for storing more, keeping it longer, or knowing more about who sent it. The
30-day ceiling, the rotating tokens, the delete-on-confirm, and the absence of logs are all
unaffected by how big the payload is — which is exactly why attachments are a cost question rather
than a privacy question. The privacy question is EXIF, and the answer is: strip it.


---

<!-- WHATSAPP-GAPS.md -->

# WhatsApp gaps

# What WhatsApp has that we do not, cheapest first

*3 September 2026. Response to: "review all the other user flow processes and settings pages for
whats app to see what else we should use as the wa alternative app. only the low cost simple to
implement things first — go for the low fruit before the high fruit."*

Ranked strictly by cost, not by how good the feature is. Anything whose value depends on
convincing somebody to switch is noted, because that is the only reason to close a gap at all —
`STRATEGY-NOTE.md` is explicit that parity is table stakes that buys nothing on its own.

## Already have

Archive · block · delete message · delete conversation · report · per-conversation notification
sound · pairing by QR, by code, and by split code · contact browsing without ingesting · transport
toggles per rung · check cadence · quiet hours · translation offer · relay opt-in.

## Tier 1 — hours, no protocol change, no new permission

These are the ones worth doing now. Every one is local state and a menu item.

| | Why it earns its place |
|---|---|
| **Copy message text** | The single most missed absence in any messenger. One context-menu item. |
| **Mute a conversation** | We already have per-conversation notification settings, so this is one more flag on a path that exists. |
| **Mark as unread** | One bool. People use it as a to-do list, and its absence is noticed immediately. |
| **Pin a conversation** | One bool and a sort key. |
| **Screen lock (Face ID / biometric)** | Highest value-per-hour on this page. A privacy messenger that anyone can read by picking up an unlocked phone is a poor one, and both platforms give this away in an afternoon. |
| **Photos over direct paths only** | Don already asked for this. The setting exists in spirit — `DeliveryPolicy` — and now has something to govern. |

## Tier 2 — a day or two each, still no new protocol

| | Note |
|---|---|
| **Search within a conversation** | Local only. Cheap because we never index anything server-side. |
| **Storage usage screen** | Newly worth having: photos are the first thing here big enough to want managing. |
| **Rename a contact** | Trivial mechanically; wants care so it never touches the pairing identity. |
| **Font size / larger text** | Accessibility, mostly free from the platform. |

## Tier 3 — needs a protocol message, so not "low fruit"

Each of these adds a `PayloadType` and a compatibility story, which is the real cost rather than
the UI.

- **Reactions** — a new payload keyed to a message id.
- **Delete for everyone** — a retraction message, plus the honest caveat that we cannot guarantee
  the other device honours it.
- **Edit a sent message** — same shape.
- **Read receipts** — the payload type already exists and nothing sends it. Should be **off by
  default**: it tells the sender when you read something, which is exactly the kind of timing
  metadata `J57` argues about.
- **Disappearing messages, per conversation** — strongly on-brand, and the one Tier 3 item worth
  jumping the queue for.

## Tier 4 — deliberately not doing

- **Reply / quote and forward.** Not for effort — for `J59`: quoting is precisely the feature that
  turns compression into a CRIME oracle. Doable, but it must land *with* the decision to drop text
  compression, not before it.
- **Typing indicators.** A continuous stream of "this person is at their phone right now" is the
  purest form of the metadata this app exists not to emit.
- **Cloud chat backup.** The conversation store is deliberately excluded from iCloud and Android
  backup. Adding backup would undo the one guarantee everything else rests on.
- **Status / stories, channels, communities, payments.** Different products.
- **Voice and video calls.** Not cheap, and `DECISIONS.md` already routes calling to the phone's
  own dialler per contact.

## The honest framing

Tier 1 exists because their absence makes the app feel unfinished to somebody comparing it with
what they already use — not because any of them is a reason to switch. The reason to switch is the
thing WhatsApp cannot do at all: work with no internet. Spend the cheap hours on Tier 1 so nothing
looks broken, and spend the expensive ones on the radio.

---

## Photo send flow — Don, 3 Sep 2026 (evening testing)

*"image when selected sends immediately - on WA you have to hit send after you select
photo... WA lets you add a caption before you send so maybe better to follow WA here for
consistency. Showing a thin progress bar during photo upload would be a nice touch though and
still show the check when it arrives and the progress bar disappears - could be blue in
progress with a brief green upon arrival or red if it failed."*

### 1. Selecting a photo must not send it

Today the picker's completion sends. That is a **destructive action with no confirmation step**,
and it is the one place in the app where a mis-tap cannot be undone: the bytes are already
sealed and deposited. WhatsApp's flow exists because people pick the wrong photo constantly.

Required: picker → **preview screen** with the image, a caption field, and an explicit Send.
Cancel discards. The caption rides in the same payload rather than as a second message, so a
photo and its caption cannot arrive apart or out of order.

### 2. A thin progress bar under the bubble, colour-coded

| State | Colour | Behaviour |
|---|---|---|
| Sending | blue | **thick enough to read across the room**, advancing by chunks acknowledged |
| Arrived | green | brief flash, then the bar disappears and the existing check remains |
| Failed | red | bar becomes a strip reading **Failed** with two buttons: **Resend** and **Cancel** |

**Cancel removes the image and the bar entirely** -- the bubble disappears as though it
had never been sent, because from the recipient's side it never was. **Resend** restarts
from the first chunk. Neither is a menu item or a long-press: a failed send is the moment a
person is least willing to hunt for a control, so both are buttons, in place, already on
screen.

**Why this is worth more here than in most messengers:** attachments are chunked into 16 KB
pieces (`J58`), a 300 KB photo is ~19 of them, and on Bluetooth that is tens of seconds during
which the app currently shows nothing at all. The bar is not decoration — it is the only signal
that a slow path is working rather than stuck.

Progress must be driven by **chunks acknowledged**, never by a timer: a fake bar that completes
while delivery has stalled is worse than no bar, and this transport stalls often enough to make
that a real risk rather than a hypothetical one.

**Both platforms, same colours, same placement.**


---

<!-- WHATSAPP-LANGUAGES.md -->

# Languages

# WhatsApp Language Support — The Two Numbers to Beat

Research date: **2026-09-03**. Prepared for Channel Messenger competitive positioning.

---

## BLUNT SUMMARY

There are **two entirely separate numbers**. They are constantly conflated in press coverage. Do not merge them.

| Axis | WhatsApp's number | Confidence | Cost to match |
|---|---|---|---|
| **AXIS 1 — UI / interface localization** (buttons, menus, settings) | **~60 languages (Android)**, **39 languages (iOS)** | Android: medium. iOS: **high — verified on the App Store listing today** | Cheap. It is translation of a finite string catalogue — a few thousand strings, one-time cost plus maintenance. |
| **AXIS 2 — message translation** (translating what people send you) | **21 languages (iOS)**, **6 languages (Android stable) / 19 (Android beta)** | High for iOS and Android-stable; Android-beta is beta-only as of Mar 2026 | Cheap-ish *if* you use an off-the-shelf on-device engine. Google ML Kit gives you **59 languages** on-device for free — nearly **3x WhatsApp's best platform**. |

### The floor to beat

- **UI localization floor: 60.** Ship 60+ interface languages and you have matched WhatsApp's best platform. Ship ~40 and you have matched WhatsApp on iOS.
- **Message translation floor: 21.** That is WhatsApp's *best* platform (iOS). Their Android stable build is at **6**. This is a strikingly weak number and the single most beatable metric on this page.

### The strategic point

**Message translation is where WhatsApp is weak, and it is the axis that is cheap to beat.** Google's ML Kit on-device translation API supports **59 languages** for free, on-device, with no server round-trip — which is a stronger privacy story than WhatsApp's, at 59 vs 21. A competitor shipping ML Kit out of the box beats WhatsApp's translation coverage by ~3x on day one, on both platforms, without compromising end-to-end encryption.

The UI axis is the opposite: 60 languages is real translation work, but it is *only* translation work, and it is the axis where WhatsApp is actually strong.

---

## AXIS 1 — INTERFACE / UI LOCALIZATION

### Q1. How many interface languages on iOS vs Android? Why different?

**VERIFIED (iOS):** **39 languages.** The App Store listing for WhatsApp Messenger states "English and 38 more." Checked 2026-09-03 against app version 26.34.72.

Full iOS list as declared on the App Store:

> English, Arabic, Bengali, Catalan, Croatian, Czech, Danish, Dutch, Finnish, French, German, Greek, Gujarati, Hebrew, Hindi, Hungarian, Indonesian, Irish, Italian, Japanese, Korean, Malay, Marathi, Norwegian Bokmål, Persian, Polish, Portuguese, Romanian, Russian, Simplified Chinese, Slovak, Spanish, Swedish, Thai, Traditional Chinese, Turkish, Ukrainian, Urdu, Vietnamese

(Counted: 39.)

**MEDIUM CONFIDENCE (Android):** **~60 languages.** Wikipedia's WhatsApp infobox states verbatim: "Available in 40 (iOS) and 60 (Android) languages," citing the WhatsApp Help Center. Search-engine indexing of the WhatsApp FAQ page surfaces an Android list containing languages absent from the iOS list — including Afrikaans, Albanian, Azerbaijani, Bulgarian, Estonian, Filipino, Kannada, Kazakh, Lao, Latvian, Lithuanian, Macedonian, Malayalam, Punjabi, Serbian, Slovenian, Swahili, Tamil, Telugu, and Uzbek — which is consistent with a ~60 figure.

⚠️ **Caveat:** I could not load the WhatsApp FAQ page directly to count it (see Q3). The 60 figure is corroborated but not first-hand verified. Treat as **±5**.

**Why the numbers differ (INFERRED, but well-supported):** The gap is roughly 20 languages, and the missing ones are concentrated in two buckets — South and Southeast Asian languages (Tamil, Telugu, Kannada, Malayalam, Punjabi, Lao, Filipino) and Central/Eastern European and Central Asian languages (Kazakh, Uzbek, Azerbaijani, Albanian, Macedonian, Serbian, Slovenian, Bulgarian, Estonian, Latvian, Lithuanian), plus Afrikaans and Swahili. These map closely onto **Android-dominant markets**. WhatsApp localizes where its Android install base is, and iOS share in those markets is small enough that the localization does not pay for itself. This is a market-priority decision, not a technical constraint.

**Note the asymmetry direction:** it is the *reverse* of the message-translation axis, where iOS is far ahead. Do not assume one platform is simply "better supported."

### Q2. What do the store listings declare?

**App Store — VERIFIED.** The App Store has a formal `Languages` metadata field in the Information section. WhatsApp declares **39** (list above). This is the single most authoritative, machine-checkable public number for iOS interface localization, and it is what a competitor's listing will be compared against.

- Source: https://apps.apple.com/us/app/whatsapp-messenger/id310633997

**Google Play — VERIFIED (by absence).** **Google Play does not publish an equivalent field.** There is no "supported languages" declaration on a public Play Store listing page. What Play exposes is the set of languages the *store listing metadata itself* has been translated into (title, description, screenshots) — configured under Play Console → Grow users → Translations → Store listings — which is a **different thing** from the languages the app's UI ships in, and is not surfaced as a count to users.

**This is a significant asymmetry and a trap.** Any "WhatsApp supports N languages" claim sourced from Play is either (a) actually the app's in-app language menu, or (b) store-listing metadata locales. Neither is the App Store number. Play supports up to ~77–83 locales for store listing metadata, but that number describes the *store page*, not the *app*.

**Practical implication for Channel Messenger:** on iOS your language count is public and directly comparable. On Android it is not, so the Android number is a marketing claim you make yourself rather than one the store verifies.

### Q3. Is there an authoritative WhatsApp FAQ page?

**Yes — but I could not retrieve it, and I am flagging that rather than papering over it.**

- **"About the languages WhatsApp is available in"** — https://faq.whatsapp.com/873422324183264

This is the authoritative page and is the source Wikipedia cites. **I was unable to quote it.** Direct HTTP fetch returns WhatsApp's generic error page; the rendered page returns an empty document body under automation (bot detection). I did not attempt to circumvent that. A human opening the URL in a normal browser will see the list immediately.

⚠️ **Action item: someone should open that URL manually and count the Android list.** It is a two-minute task and it converts the headline "~60" from medium to high confidence. It is the one genuinely load-bearing gap in this report.

Related but **easily confused** page — do not cite this one for interface languages:

- **"About WhatsApp support languages"** — https://faq.whatsapp.com/1079702866364136 — this concerns the languages WhatsApp's **customer support** operates in, not the app UI. Different number, different meaning. (Also could not be retrieved; flagged so nobody cites it by mistake.)

---

## AXIS 2 — MESSAGE TRANSLATION

### Q4. Does WhatsApp have built-in message translation?

**VERIFIED. Yes.** Announced **23 September 2025**, on both Android and iPhone, rolling out gradually from that date.

Primary sources:
- WhatsApp Blog: https://blog.whatsapp.com/introducing-message-translations
- Meta Newsroom: https://about.fb.com/news/2025/09/introducing-message-translations-whatsapp/

**On-device, not server-side — VERIFIED.** Meta's own wording:

> "translations occur on your device where WhatsApp cannot see them"

Users download language packs to the device; once downloaded, translation works without a connection. This preserves end-to-end encryption — no plaintext leaves the device for translation.

**Mechanics:** long-press a message → **Translate** → pick source/target language. Works in 1:1 chats, group chats, and Channel updates.

**Platform-exclusive feature:** Android additionally offers **automatic translation of an entire chat thread** (all future incoming messages auto-translated). iOS launched *without* this — manual, per-message only. As of March 2026, automatic thread translation was **in TestFlight for iOS but not shipped**, with no announced timeline.

### Q5. How many languages, and which?

This is where WhatsApp is weak, and the numbers are very different per platform.

**iOS — 21 languages. VERIFIED.**

> Arabic, Chinese (Mandarin, Simplified and Traditional), Dutch, English (UK and US), French, German, Hindi, Indonesian, Italian, Japanese, Korean, Polish, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, Vietnamese

The count of 21 treats English UK/US and Chinese Simplified/Traditional as separate entries. As **distinct languages** it is closer to **19**, which is why press coverage says both "19" and "21" — they are the same fact counted two ways. Meta's own launch wording was the vaguer "**19+ languages**."

⚠️ **Key structural insight:** WhatsApp on iOS does not own this list. It is **Apple's Translation framework** language set. WhatsApp inherits it. As Apple adds languages, WhatsApp's coverage grows for free — and WhatsApp cannot exceed it without building its own engine. *A competitor using Apple's framework on iOS gets exactly the same 21 and cannot differentiate there. To beat WhatsApp on iOS you must ship your own engine or bundle ML Kit.*

**Android — 6 languages stable. VERIFIED.**

> English, Spanish, Hindi, Portuguese, Russian, Arabic

That is the shipped, generally-available number, straight from Meta's launch post.

**Android — 19 languages in beta. VERIFIED as beta-only, as of March 2026.**

WhatsApp beta for Android **2.26.12.4** (24 March 2026) added **13** language packs: Czech, German, French, Indonesian, Italian, Latvian, Dutch, Polish, Swedish, Turkish, Ukrainian, Urdu, Simplified Chinese. 6 + 13 = **19**.

- Source: https://wabetainfo.com/whatsapp-beta-for-android-adds-13-language-packs-for-message-translations/

⚠️ **NEEDS VERIFICATION — highest-priority stale item in this report.** That was beta-only in March 2026 and it is now September 2026. Six months is long enough for it to have reached stable. **Check the current Android stable build before quoting "6" in any public-facing comparison** — quoting 6 when they now ship 19 is an unforced error that would undermine the whole claim. Assume 19 for planning; verify before publishing.

### Q6. Is translation on both platforms, and is coverage different?

**Yes to both, and the difference is large.**

| | iOS | Android |
|---|---|---|
| Manual per-message translation | Yes | Yes |
| **Auto-translate whole thread** | **No** (TestFlight only as of Mar 2026) | **Yes** — since launch |
| Language count | **21** | **6** stable / **19** beta (verify) |
| Engine | Apple Translation framework | WhatsApp's own downloadable packs |
| On-device | Yes | Yes |

**The two platforms are each better at the thing the other is worse at.** iOS has ~3x the languages but lacks the headline convenience feature. Android has auto-translate but launched with only 6 languages.

**Competitive read:** neither platform is complete. A competitor that ships **both** wide language coverage **and** automatic thread translation, **on both platforms**, beats WhatsApp outright on this axis — and as of today nobody has to choose, because ML Kit provides 59 languages on both.

---

## COMPARISON POINTS

### Q7. Google ML Kit on-device translation

**VERIFIED: 59 languages.**

I counted the official supported-languages table directly: 60 table rows minus 1 header row = **59 language entries**. (An automated summary of the same page reported "107" — that was wrong, and is exactly the kind of miscount that propagates into competitive decks. The number is 59.)

- Source: https://developers.google.com/ml-kit/language/translation/translation-language-support
- Overview: https://developers.google.com/ml-kit/language/translation

BCP-47 codes: af, ar, be, bg, bn, ca, cs, cy, da, de, el, en, eo, es, et, fa, fi, fr, ga, gl, gu, he, hi, hr, ht, hu, id, is, it, ja, ka, kn, ko, lt, lv, mk, mr, ms, mt, nl, no, pl, pt, ro, ru, sk, sl, sq, sv, sw, ta, te, th, tl, tr, uk, ur, vi, zh

**Same set as Google Translate offline? VERIFIED — yes.** Google's own documentation states ML Kit translation is "powered by the same models used by the Google Translate app's offline mode."

⚠️ **Two important caveats before betting on this:**

1. **English is a pivot.** ML Kit models are trained to translate to and from English. Non-English → non-English pairs route **through English as an intermediate step**, which degrades quality. Japanese→Korean is really Japanese→English→Korean. For a messenger where many real conversations are non-English-to-non-English, this is a genuine quality risk, not a footnote.
2. **Google Translate's *online* service supports far more languages** (well over 100) than its offline mode. Do not conflate the 59 offline/on-device figure with Google Translate's full catalogue. Only the 59 works on-device, and only on-device preserves your encryption story.

**The headline remains strong:** 59 on-device languages, free, privacy-preserving, vs WhatsApp's best-platform 21.

### Q8. Signal and Telegram interface languages

**Signal — 44 languages. VERIFIED (App Store, 2026-09-03).** Listing declares "English and 43 More":

> English, Arabic, Belarusian, Bengali, Bulgarian, Catalan, Croatian, Czech, Danish, Dutch, Finnish, French, German, Greek, Gujarati, Hebrew, Hindi, Hungarian, Indonesian, Irish, Italian, Japanese, Korean, Lithuanian, Malay, Marathi, Norwegian Bokmål, Persian, Polish, Portuguese, Romanian, Russian, Serbian, Simplified Chinese, Slovak, Spanish, Swedish, Thai, Traditional Chinese, Turkish, Uighur, Ukrainian, Urdu, Vietnamese

Signal localizes via community translation on Transifex (https://explore.transifex.com/signalapp/), which carries more locales than ship in the app — Signal only accepts translations for locales the underlying OS/Electron supports. **Signal beats WhatsApp on iOS (44 vs 39) despite a fraction of the resources**, because community translation is cheap. That is a directly relevant model for Channel Messenger.

**Telegram — 19 on the App Store, but that number is misleading. VERIFIED with caveat.**

App Store declares "English and 18 more": English, Arabic, Belarusian, Catalan, Dutch, French, German, Indonesian, Italian, Korean, Malay, Persian, Polish, Portuguese, Russian, Spanish, Turkish, Ukrainian, Uzbek.

⚠️ **Do not use 19 as Telegram's real coverage.** Telegram ships **downloadable in-app language packs** via its localization platform (https://translations.telegram.org/). Reported availability is **~66 languages**, of which roughly a dozen are "official" and the rest are community-maintained and installable from within the app. Because these are runtime-downloaded rather than bundled, they never appear in App Store metadata.

**This is a legitimate strategy worth copying:** Telegram gets broad coverage without carrying the QA burden of officially supporting every locale, and without bloating the binary.

### Scoreboard — UI localization

| App | iOS (App Store, verified) | Notes |
|---|---|---|
| **WhatsApp** | **39** | ~60 on Android |
| **Signal** | **44** | Community-translated via Transifex |
| **Telegram** | 19 bundled | ~66 via in-app downloadable packs |

---

## VERIFIED vs INFERRED — summary

**VERIFIED (primary source, checked 2026-09-03):**
- WhatsApp iOS interface: **39** (App Store, v26.34.72)
- Signal iOS interface: **44** (App Store)
- Telegram iOS interface: **19 bundled** (App Store)
- WhatsApp message translation exists, launched **23 Sept 2025**, **on-device**, both platforms (Meta/WhatsApp official)
- WhatsApp Android translation at launch: **6** — English, Spanish, Hindi, Portuguese, Russian, Arabic (Meta official)
- WhatsApp iOS translation: **21** entries / 19 distinct languages, via **Apple's Translation framework**
- Android-exclusive auto-translate whole thread; iOS lacked it as of Mar 2026
- Android beta added 13 packs → **19**, beta 2.26.12.4, 24 Mar 2026 (WABetaInfo)
- ML Kit on-device translation: **59** languages, same models as Google Translate offline (counted from Google's own table)
- Google Play publishes **no** app-interface-language field

**INFERRED / MEDIUM CONFIDENCE:**
- WhatsApp Android interface **~60** — Wikipedia citing WhatsApp FAQ, plus search-indexed fragments of the FAQ. Not counted first-hand. **±5.**
- **Why** iOS < Android on UI — market-share reasoning from the shape of the missing-language list. Sound but not stated by WhatsApp.
- Telegram **~66** via language packs — from secondary/wiki sources, not counted on Telegram's platform directly.

**⚠️ STALE-RISK — verify before any public claim:**
1. **WhatsApp Android translation count.** 19 was **beta-only in March 2026**; it is now September 2026 and has likely shipped. **Do not publish "WhatsApp Android only translates 6 languages" without re-checking.** This is the single highest-risk claim in this document.
2. **iOS auto-translate.** Was TestFlight-only in March 2026. May have shipped. If it has, WhatsApp's iOS offering is 21 languages *plus* auto-translate, which is a materially stronger competitor.
3. **Apple Translation framework language count.** WhatsApp's iOS number moves whenever Apple's does. 21 is an Apple number, not a WhatsApp one — recheck Apple's feature-availability page.
4. **WhatsApp FAQ Android list.** Needs one manual browser visit to firm up the ~60.
5. **ML Kit's 59.** Google adds languages periodically. Recheck at integration time.

**General currency warning:** everything after roughly mid-2025 is fast-moving. The translation feature is barely a year old and was still actively expanding as of March 2026 — the most recent hard datapoint in this report is six months stale.

---

## SOURCES

**WhatsApp primary**
- [About the languages WhatsApp is available in — WhatsApp Help Center](https://faq.whatsapp.com/873422324183264) ⚠️ *could not be retrieved by automation; open manually*
- [About WhatsApp support languages — WhatsApp Help Center](https://faq.whatsapp.com/1079702866364136) *(support languages, NOT UI languages)*
- [Introducing Message Translations — WhatsApp Blog](https://blog.whatsapp.com/introducing-message-translations)
- [Introducing Message Translations on WhatsApp — Meta Newsroom](https://about.fb.com/news/2025/09/introducing-message-translations-whatsapp/)

**Store listings**
- [WhatsApp Messenger — App Store](https://apps.apple.com/us/app/whatsapp-messenger/id310633997)
- [Signal Private Messenger — App Store](https://apps.apple.com/us/app/signal-private-messenger/id874139669)
- [Telegram Messenger — App Store](https://apps.apple.com/us/app/telegram-messenger/id686449807)
- [WhatsApp — Google Play](https://play.google.com/store/apps/details?id=com.whatsapp)
- [Translate and localize your app — Play Console Help](https://support.google.com/googleplay/android-developer/answer/9844778)

**Google ML Kit**
- [ML Kit translation: supported languages](https://developers.google.com/ml-kit/language/translation/translation-language-support)
- [ML Kit Translation overview](https://developers.google.com/ml-kit/language/translation)

**Competitors**
- [Signal localization — Transifex](https://explore.transifex.com/signalapp/)
- [Language Options — Signal Support](https://support.signal.org/hc/en-us/articles/360049188372-Language-Options)
- [Telegram Localization Platform](https://translations.telegram.org/)

**Secondary / corroborating**
- [WhatsApp — Wikipedia](https://en.wikipedia.org/wiki/WhatsApp) *(infobox: "40 (iOS) and 60 (Android) languages")*
- [WhatsApp beta for Android adds 13 language packs — WABetaInfo](https://wabetainfo.com/whatsapp-beta-for-android-adds-13-language-packs-for-message-translations/)
- [WhatsApp for iPhone adds built-in message translation in 21 languages — 9to5Mac](https://9to5mac.com/2025/10/07/whatsapp-for-iphone-adds-built-in-message-translation-in-21-languages/)
- [WhatsApp for iPhone working on automatic message translation — 9to5Mac, Mar 2026](https://9to5mac.com/2026/03/20/whatsapp-for-iphone-may-soon-offer-automatic-message-translation-across-21-languages/)
- [WhatsApp Rolls Out Message Translation on iOS in 19 Languages — MacRumors](https://www.macrumors.com/2025/10/08/whatsapp-rolls-out-message-translation/)
- [WhatsApp gets message translations, auto-translation exclusive to Android — 9to5Google](https://9to5google.com/2025/09/23/whatsapp-message-translations-auto-translation-support-exclusive-to-android/)


---

<!-- UI-CONSISTENCY.md -->

# UI consistency

# One app, four clients

*3 September 2026. Don: "make sure the UI and look of icons is identical between the apps … this
should be as 100% consistent as the OS will allow and will hold for the MacOS and Windows clients
as well … We want to leverage people's familiarity with other messaging apps and particularly with
WA such that there will be almost no learning curve … the behind the scenes work is where most of
the difference will live."*

This is the reference the iOS, Android and future desktop clients all build against. It exists
because the drift was already real after two clients; with four it would be unfixable.

## The rule

**Where WhatsApp has solved a layout, copy the solution.** Not the branding, not the colours — the
*shape*: what lives in which tab, what a long-press offers, where the archive sits, what tapping a
name does. Every hour a user spends learning our navigation is an hour spent on something that is
not the reason to use this app.

**Where we differ, differ for a reason and only in the places the reason applies.** Pairing has no
WhatsApp equivalent because WhatsApp has phone numbers and we do not. That justifies a different
*setup* flow. It does not justify a different contact list.

**Platform idiom wins over cross-platform sameness where they collide.** A back gesture, a
navigation bar, a share sheet — use what the OS gives, because a user's familiarity with their
*phone* outranks their familiarity with our other client. Everything above that layer matches.

## Structure

Three top-level destinations, in this order, on every client.

| | Contains | Archive |
|---|---|---|
| **Chats** | Active conversations, newest first, pinned above | **A row at the top of the list**, not a tab and not a menu item — exactly WhatsApp's placement |
| **Contacts** | Everyone paired or named, whether or not a word has been exchanged | — |
| **Settings** | Everything else | — |

Chats and Contacts are **separate lists of different things** and must stay so: a conversation is a
thread, a contact is a person, and a person with no thread still exists.

## Tapping a name opens their profile

From either list, and from the conversation header. One screen, the same on every client, in this
order:

1. **Name**, editable in place. We own this string — there is no address book behind it — so
   renaming is ours to offer and must exist on every client.
2. **Pairing state**, and the verification code when there is one.
3. **Message**, which opens or creates the thread.
4. **Mute**, and **notification sound for this conversation**.
5. **Block**, **Report**, **Delete**.

Deliberately *not* here yet, and each is a Tier 2/3 item rather than an omission: shared media,
disappearing-message timer, and a per-contact calling handoff.

## The icon vocabulary

One meaning, one icon, everywhere. The two platforms name them differently; the *shape* must read
the same.

| Meaning | SF Symbol (Apple) | Material (Android/desktop) |
|---|---|---|
| Add a contact | `person.badge.plus` | `PersonAdd` |
| Pair / scan a code | `qrcode.viewfinder` | `QrCodeScanner` |
| Show my code | `qrcode` | `QrCode2` |
| Message someone | `message` | `Chat` |
| Contacts list | `person.2` | `People` |
| Settings | `gear` | `Settings` |
| Archive | `archivebox` | `Archive` |
| Mute | `bell.slash` | `NotificationsOff` |
| Pin | `pin` | `PushPin` |
| Mark unread | `envelope.badge` | `MarkEmailUnread` |
| Copy | `doc.on.doc` | `ContentCopy` |
| Attach a picture | `photo` | `Image` |
| Message info | `info.circle` | `Info` |
| Block | `hand.raised` | `Block` |
| Delete | `trash` | `Delete` |

**Adding a row here is the cheap moment.** Adding an icon to one client without adding it here is
how the next divergence starts, and the drift this document was written to fix took two clients and
about a week to appear.

## Known divergences, to be closed

Found auditing on 3 Sep 2026:

- **Rename existed on Android and not iOS.** Closed.
- **Pairing was one button on iOS and three on Android** (Show my code / Scan their code / Enter a
  code). One entry point, then the choice inside — the shorter list is the better one.
- **The add-contact icon differed**, which is what Don noticed first.

## What does *not* have to match

The parts a user never sees, and the parts where honesty beats symmetry:

- **Transport, storage and crypto** — all of it, which is where the real work is.
- **Anything one platform genuinely cannot do.** iOS has no system notification-sound picker, so it
  ships bundled sounds where Android offers every sound on the phone. Faking parity by hiding
  Android's picker would make both worse.


## Big screens: iPad, macOS, Windows, Android tablets (`J100`)

Same shape and same words on every platform, never the same pixels.

**Panes.** Three when the window is wide (rail · list · thread), two when medium, one when
phone-width. Width decides, not device, so a resized window behaves like a smaller device.

**Thread header.** Picture, name, status letters beneath the name, call and video at the right.
Back appears only in one-pane mode.

**Menu bar** (macOS, iPadOS 26+, Windows -- and nothing may live *only* here):

| Menu | Items | Shortcut |
|---|---|---|
| File | New Chat · New Contact · Check Now | Cmd/Ctrl N · Shift N · R |
| Profile | switch profile · Manage Profiles | Cmd/Ctrl 1-9 |
| View | Messages · Contacts · Settings | Cmd/Ctrl Alt 1-3 |
| Help | How Channel picks a path · Privacy | -- |

**Input.** Pointer hover states, arrow keys through the list, Return to send wherever a keyboard
exists.

A new platform implements this section. It does not copy screenshots of the others.


---

<!-- EXPORT-COMPLIANCE.md -->

# Export compliance

# EXPORT-COMPLIANCE.md — US encryption export controls

**Status: measured, 2026-09-01.** Checked against primary sources on this date — not recalled from
training. Re-verify before submission; export regulations change and this file will go stale.

**Not legal advice.** The ambiguity in §3 needs export counsel before first submission.

---

> ## ✅ SETTLED — 5 September 2026: exempt, on a good-faith reading
>
> **`app/project.yml` sets `ITSAppUsesNonExemptEncryption: false`**, and that is the answer every
> shipped build carries. This banner previously claimed the opposite; it was wrong about the code.
>
> Don, 5 Sep 2026: *"we already discussed the non-exempt thing ... the controversy was whether our
> protocols were somehow not standard but our protocols don't speak to the method of encryption so we
> want our best good faith estimate as to our compliance posture without creating undue burden by
> over broad interpretation of what is exempt vs non exempt."*
>
> **The reasoning.** The app defines no cryptography of its own. It uses X25519, Ed25519,
> ChaCha20-Poly1305, SHA-256 and AES-GCM, all standard and published. `PROTOCOL.md` specifies
> routing, addressing, tokens and retention -- it does not specify a cipher, a mode, or a key
> schedule of its own invention. Implementing a published algorithm in Rust rather than calling the
> operating system's copy does not make the algorithm non-standard; the earlier sections of this
> document treated it as if it did, and that was the over-broad reading Don is rejecting.
>
> The functionality is ordinary confidentiality for personal communication: not special-purpose, not
> for government or military use, no proprietary cryptography.
>
> **What must stay in agreement.** Three places say this, and they must never disagree again:
> `app/project.yml` (`false`), `web/encryption.html` §6, and this banner. The website said `true`
> until 5 Sep 2026, which is how the disagreement was found.
>
> **What would reopen it.** Shipping a primitive of our own, a non-published algorithm, or a
> special-purpose mode. Sections 3 to 6 below are kept as the record of the earlier analysis and its
> stricter reading; where they conflict with this banner, this banner is the decision.

---

---

## 1. Strength is not the axis

Key-length thresholds were the 1990s regime — the 40-bit and 56-bit limits, the PGP prosecution,
Bernstein. Those are gone. Using X25519 and ChaCha20-Poly1305 does not put this project in a worse
position than weaker choices would, and deliberately weakening anything would buy nothing.

What determines obligations is **what kind of item it is, whether the source is public, and where it
goes.**

Framework: Export Administration Regulations (EAR), administered by the Commerce Department's Bureau
of Industry and Security. Encryption software sits in Category 5 Part 2 of the Commerce Control List.
This app classifies as **ECCN 5D002**.

---

## 2. Two routes, and open source is dramatically simpler

### Route A — publicly available source code

> **15 CFR 742.15(b)(1):** "Publicly available encryption source code classified under ECCN 5D002 is
> not subject to the EAR."

Not "exempt." **Not subject.** No annual report, no classification request, no filing — provided the
source is genuinely publicly available.

> **15 CFR 742.15(b)(2):** notification to BIS and the ENC Encryption Request Coordinator by email is
> required **only** for publicly available source code that "provides or performs *non-standard
> cryptography*."

**Discrepancy on record.** BIS's own plain-language guidance page states that publicly available
source "is not subject to the EAR once the email notification per section 742.15(b) is sent" —
presenting notification as a blanket condition. The regulation limits it to non-standard
cryptography. **BIS's summary is stricter than BIS's rule.**

**Resolution: if we open-source, send the notification anyway.** One email to `crypt@bis.doc.gov` and
`enc@nsa.gov` with the repository URL. It costs nothing and moots §3's ambiguity entirely, whichever
reading is correct.

### Route B — closed source

Export under **License Exception ENC §740.17(b)(1)**, which carries a recurring obligation:

| | |
|---|---|
| **What** | Annual self-classification report |
| **To** | ENC Encryption Request Coordinator |
| **By** | **1 February**, covering the prior calendar year (1 Jan – 31 Dec) |
| **Format** | CSV only, twelve fields: product name, model number, manufacturer, ECCN, authorization type, item type, submitter name, telephone, email, mailing address, non-US components, non-US manufacturing locations |
| **Waived if** | A CCATS classification has been submitted for the item |
| **Not required if** | No applicable exports occurred that year |

Each product is reported once, in the year it was self-classified. If nothing changed since the prior
report, an email saying so suffices.

The second year is where this gets forgotten. Put it in a calendar, not in someone's head.

---

## 3. The open question — is our *protocol* "non-standard cryptography"?

EAR Part 772 defines non-standard cryptography as proprietary or unpublished cryptographic
functionality, *"including encryption algorithms **or protocols** that have not been adopted or
approved by a duly recognized international standards body"* — IEEE, IETF, ISO, ITU, ETSI, 3GPP, TIA,
GSMA.

**Our primitives are unambiguously standard.** X25519, ChaCha20-Poly1305, HKDF-SHA256, HMAC-SHA256 —
all published IETF specifications (`PROTOCOL.md` §4.1). The project rule against inventing
cryptography settles the algorithm question completely.

**Our protocol is not standards-body adopted, and will not be.** The Envelope, the fixed-layout AAD,
the token derivation, the route-discovery state machine — all bespoke.

Two readings:

- **Narrow** (non-standard means *secret or proprietary primitives*): we are clearly fine. This is
  also how the industry operates — the Signal Protocol is not an IETF standard, and Signal and
  WhatsApp ship without CCATS.
- **Literal** ("or protocols"): a custom protocol assembled from public parts is arguably reached.

### Working determination, 2 Sep 2026

**We self-classify as mass market using standard cryptography, and record the basis here.** Don's
reading, which drives this: *"the nature of the encryption is what their concern is... the content of
what is encrypted is not really a protocol that affects the nature of the encryption."*

That is right about the axis. Envelope framing, transport selection and fragment reassembly do not
change what X25519 or ChaCha20-Poly1305 do, or how hard either is to break. §1 already says strength
is not the axis; capability is.

**But the argument must not rest on "ours is not a cryptographic protocol,"** because parts of it are
and a knowledgeable reviewer would say so: the handshake is a station-to-station exchange with a
signed transcript, the HKDF info strings bind specific material, the AAD layout is fixed for security
reasons, and destination tokens are derived from keys and time to be unlinkable. Those determine
security properties. A flawed handshake breaks everything regardless of primitive strength.

**The basis we actually rely on** is the definition's own wording — non-standard cryptography is
*proprietary or unpublished* functionality:

| Test | Us |
|---|---|
| Proprietary primitives? | **No.** X25519, Ed25519, ChaCha20-Poly1305, HKDF-SHA256, SHA-256 — all published IETF/FIPS, none invented here. The project rule against inventing cryptography settles it. |
| Unpublished? | **No, as of 2 Sep 2026.** <https://channelmessenger.netlify.app/encryption.html> documents the algorithms and their use, and `PROTOCOL.md` goes to anyone who asks. |
| Novel construction? | **No.** A signed ephemeral exchange with HKDF-derived keys is textbook. Signal's Double Ratchet is substantially more inventive and ships as mass market. |

#### Prior art for every mechanism — the concrete basis for "not novel"

Don asked the right question directly: *"Novel? Are we novel?"* The test is whether each mechanism can
be traced to existing published work. Every one can:

| Mechanism | Prior art |
|---|---|
| Ed25519, X25519, ChaCha20-Poly1305, HKDF-SHA256, SHA-256 | Published IETF/FIPS specifications. `PROTOCOL.md` §4.1: no primitive outside that table appears anywhere. |
| Handshake v1 — signed ephemeral DH, three flights | Station-to-Station; Diffie, van Oorschot & Wiener, 1992. |
| HMAC-SHA256 truncated to 16 bytes for tokens | HMAC as a PRF, RFC 2104. Truncation is standard practice. |
| Domain separation by label (`mesh/presence-v1` vs `mesh/destination-v1`) | The purpose of HKDF/HMAC labels; TLS 1.3's key schedule does the same. |
| Direction byte, so both parties derive different tokens from one secret | TLS deriving `client_write_key` and `server_write_key` from one master secret. |
| Epoch rotation, `floor(unixTime / 600)` | TOTP (RFC 6238) is `floor(T/X)`. BLE Resolvable Private Addresses rotate identically. **Apple and Google's Exposure Notification rolling proximity identifiers are the same shape**, published and analysed at global scale. |
| Independent keys for routing and content | Key separation; textbook. |
| Sender identity inside the ciphertext | Sealed sender; Signal, 2018. |
| Mandatory padding | Standard traffic-analysis defence. |

**Bespoke, not novel** — and that is the distinction the regulation turns on. Nothing here is an
invention; it is assembled from parts that each carry a citation.

Note also that being un-novel is a **security** property and not merely a regulatory convenience.
Novel cryptography in a shipping product is almost always a mistake, and §4.1's rules — never invent
cryptography, and no negotiable cipher suites because negotiation is a downgrade surface — are why
this table can be written at all.

The Exposure Notification comparison is the one to lead with: Apple and Google shipped HMAC-derived,
epoch-rotating, unlinkable identifiers to billions of devices. That mechanism is not treated as
non-standard cryptography.

**Why this is a determination we are entitled to make.** Self-classification is not a permission
granted to us; it is an assessment we make in good faith and document. Certainty about how a third
party would read "or protocols" is not the standard and never was. Don: *"we cannot know how somebody
else will interpret the answer, but we have to answer in good faith."* This section, and the published
page, are that record.

**Still get export counsel before external distribution**, and before any French availability (§4).
This determination is the working basis for internal testing and for answering Apple honestly; it is
not a substitute for review when the stakes rise. Open-sourcing would moot the question entirely via
the publicly-available route, which remains the cleanest resolution and is one more argument for it.

---

## 3a. What Apple's own validation told us, 2 Sep 2026

Two upload attempts and two API refusals settled how this app is actually handled, and the answer is
less onerous than §4 implies.

**Setting `ITSAppUsesNonExemptEncryption: true` in Info.plist breaks the upload:**

```
Invalid Export Compliance Code. The export compliance key value [] in the app's
Info.plist doesn't match the key value of the app's export compliance documentation.
```

Apple looks for a companion `ITSEncryptionExportComplianceCode`, which exists only for apps holding
encryption-declaration paperwork.

**Attempting to file that paperwork is refused, and the refusal is the useful part:**

```
Cannot create appEncryptionDeclarations unless either containsProprietaryCryptography
is True or containsThirdPartyCryptography and availableOnFrenchStore are both True.
```

Our honest answers are proprietary **No**, third-party **Yes**, French store **No** — a combination
Apple will not let you file a declaration for, **because that combination does not need one.** Apple's
own validation is confirming the §3 working determination from the other direction: an app using only
published third-party standard algorithms, not sold in France, files nothing.

**Therefore:** the key is omitted from Info.plist and the truthful answer is given **per build** via
`PATCH /v1/builds/{id}` with `usesNonExemptEncryption: true`. That is the same honest answer in the
place Apple actually wants it for this app's situation.

> **Never set the key to `false` to silence the prompt.** That is the false declaration builds 1–4
> carried, and it is what this document exists to prevent. Absent ≠ false: absent means "answered per
> build", and the per-build answer is *yes*.

## 3b. The question Apple actually asks — §3 is moot in their flow

Answered in App Store Connect for build 5 on 2 Sep 2026. The wording matters, because it is **not**
the EAR wording this document has been wrestling with:

> **What type of encryption algorithms does your app implement?**
> 1. Encryption algorithms that are proprietary or not accepted as standard by international standard
>    bodies (IEEE, IETF, ITU, etc.)
> 2. Standard encryption algorithms instead of, or in addition to, using or accessing the encryption
>    within Apple's operating system
> 3. Both algorithms mentioned above
> 4. None of the algorithms mentioned above

**Apple asks about ALGORITHMS. The phrase "or protocols" does not appear.** The §3 ambiguity — whether
a bespoke protocol assembled from standard parts counts as non-standard cryptography — simply does not
arise here. Our algorithms are X25519, Ed25519, ChaCha20-Poly1305, HKDF-SHA256 and SHA-256, every one
an IETF or FIPS publication, so **option 2** is unambiguously correct and required no interpretation.

Second and final question: *"Is your app going to be available for distribution in France?"* Answered
**No**, which keeps §4's separate French declaration out of scope for the beta. **Revisit this before
any App Store release that includes France.**

Result: build 5 moved from *Missing Compliance* to *Ready to Submit* and was assigned to the tester
group. No CCATS, no ERN, no BIS filing was requested at any point.

**Note for future uploads.** Apple's own dialog says the answers can be baked into Info.plist to avoid
answering per submission — but that is the route that produced the "Invalid Export Compliance Code"
upload failure in §3a, because `ITSAppUsesNonExemptEncryption: true` alone makes Apple demand a
companion code. **Answering per build in the TestFlight UI is the working path for this app.** The API
does not substitute: `PATCH /v1/builds/{id}` with `usesNonExemptEncryption` returns HTTP 200 and does
not persist.

## 4. Apple's gate

| Encryption in use | Upload to App Store Connect |
|---|---|
| Limited to what iOS provides | Nothing |
| **Industry-standard algorithms, own implementation** ← *us* | **French declaration only** |
| Proprietary algorithms not accepted by IEEE/IETF/ITU | CCATS **and** French declaration |

- `ITSAppUsesNonExemptEncryption` = **YES**. We implement a protocol; we are not limited to
  OS-provided encryption.
- The **French declaration** is required only if distributing on the App Store in France.
- **No CCATS** on the industry-standard-algorithm reading (§3).
- `ITSEncryptionExportComplianceCode` is added to Info.plist **only after** Apple approves
  documentation and issues a code — not before.
- Declare in Info.plist rather than answering the questionnaire per upload, or it reappears on every
  build.

Note for later: a dependency can change the answer even when our own code does not. Every Rust crate
we link is part of what we ship.

---

## 5. Destination restrictions

Independent of any filing. No export to embargoed destinations — currently Cuba, Iran, North Korea,
Syria, and specified Ukrainian regions. App Store territory availability handles this operationally,
but the obligation is the developer's, not Apple's.

---

## 6. Decision — Route B, closed source for v1

**Settled by Don on 2026-09-01** (`DECISIONS.md` F7). We are on Route B.

An earlier draft of this file recommended Route A. That recommendation did not account for a possible
future sale, and publishing is irreversible: every released version stays licensed forever, while
closed → open remains available at any time. Route B costs one CSV filing a year and preserves the
choice; Route A saves that filing and forecloses it.

**Live obligations from first export (App Store release):**

- [ ] Annual self-classification report — **due 1 February**, prior calendar year, twelve-field CSV,
      to the ENC Encryption Request Coordinator. Calendared, not remembered.
- [ ] `ITSAppUsesNonExemptEncryption` = YES in Info.plist
- [ ] French encryption declaration, if distributing in France
- [ ] No CCATS, on the industry-standard-algorithm reading (§3, and §4's middle row)
- [ ] Confirm §3's "or protocols" question with export counsel before first submission

**If this is revisited later**, Route A remains fully available, and the §742.15(b)(2) email should be
sent regardless of which reading of §3 applies — it costs nothing and moots the ambiguity.

**Licence caution if Route A is ever taken:** a source-available licence (Business Source, PolyForm,
or similar) may not qualify as "publicly available," since that turns on unrestricted dissemination.
Choosing one could take every downside of publishing with none of the relief. **Unverified** — check
before selecting a licence.

---

## 7. Sources — retrieved 2026-09-01

- 15 CFR 742.15 — https://www.law.cornell.edu/cfr/text/15/742.15
- BIS, annual self-classification — https://www.bis.gov/learn-support/encryption-controls/annual-self-classification
- BIS, encryption items not subject to the EAR — https://www.bis.gov/learn-support/encryption-controls/encryption-items-not-subject-to-ear
- BIS, Supplement No. 6 to Part 742 — https://www.bis.gov/ear/title-15/subtitle-b/chapter-vii/subchapter-c/part-742/supplement-no-6-part-742-technical
- Apple, export compliance documentation — https://developer.apple.com/help/app-store-connect/reference/app-information/export-compliance-documentation-for-encryption/


---

<!-- FRANCE-CRYPTO.md -->

# France crypto declaration

# FRANCE-CRYPTO.md — French cryptology declaration, and Apple's France question

Research date: **2 September 2026**. Companion to `EXPORT-COMPLIANCE.md` (US/EAR side, and the
App Store Connect answers already given for build 5).

> **This is a report of what the sources say. It is not legal advice.** Where a real judgement call
> is needed, §7 says so explicitly.

---

---

## 0. FILED — 3 September 2026, and what ANSSI's acknowledgement confirms [VERIFIED — primary]

An initial filing was emailed to `controle@ssi.gouv.fr` at **03:49:50 UTC on 3 September 2026**
(05:49:50 Paris), subject `[formalités] CHANNEL MESSENGER – Channel Messenger`, from `de57@me.com`. It
requested the Annexe I form (the published link 404s) and supplied the product description, publisher,
and the full algorithm/key-length list.

ANSSI's automated reply arrived within minutes and **settles several points this document had marked
uncertain**:

- **It was logged as a `dossier de déclaration`**, not treated as a mere enquiry: *"Nous avons bien
  reçu votre dossier de déclaration et/ou demande d'autorisation d'opérations relatives à un moyen ou
  à une prestation de cryptologie en date du jeudi 3 septembre 2026 05:49:50."* Electronic filing
  works and is acknowledged immediately.
- **One month to review**, *"étendu à deux mois lorsque la déclaration concerne la fourniture de
  prestations de cryptologie ou l'exportation de moyens de cryptologie vers des Etats non membres de
  l'Union européenne."* Confirms the *moyen* / *prestation* distinction is live in ANSSI's actual
  process, not only in the statute — and that the prestation limb costs an extra month.
- ANSSI checks two things in that month: whether the dossier is **complete**, and whether the means
  **falls under the authorisation regime rather than declaration**.
- **Silence is permission.** *"En cas de silence de l'agence, vous pourrez procéder librement, à
  l'expiration des mêmes délais, aux opérations faisant l'objet de votre déclaration et demander à
  l'ANSSI une attestation confirmant que vous vous êtes acquitté de votre obligation déclarative."*
  No approval is needed — an attestation is available on request afterwards.

**Expect an incompleteness request.** The filing carried the substance but not the Annexe I form
itself, a KBis-equivalent registration document, or the attachments §3.2 lists. Under décret art. 5
that restarts the one-month clock from receipt of the additional material, so **the operative date
will likely be the follow-up, not 3 September.**

**Open, for Don:** the declaration named *"Donald Elton (États-Unis)"* personally, matching the Apple
and Google developer accounts. If Elton Services (EIN, Florida fictitious name) is the intended filer,
`Donald Elton d/b/a Elton Services` keeps the developer-account match while giving ANSSI a
registration document to point at. Decide before the complete dossier goes back.

## Bottom line

1. **The obligation is real, still in force in 2026, and has not been superseded by EU law.** Supply
   (`fourniture`) or import of a confidentiality-capable cryptographic means in France requires a
   **prior declaration to ANSSI**, under LCEN art. 30 III (2004) and décret 2007-663. Free-of-charge
   supply counts. EU dual-use regulation 2021/821 governs *export*; it did not replace this.

2. **It is a declaration, not an approval.** You file and you may proceed — there is no waiting for a
   "yes". The statutory hook is **file at least one month before** you start supplying (décret art. 4).
   **No fee.** No lawyer legally required. The form is short; the burden is a technical description.

3. **The developer files, not Apple.** ANSSI puts the duty on "le fournisseur ou le primo-importateur",
   including foreign suppliers. Apple's App Store question is Apple enforcing the French rule against
   its developers — it is not Apple filing on your behalf.

4. **There is no exemption that plainly covers us.** Using only standard published algorithms does not
   exempt. "Grand public" (mass-market) status almost certainly applies to us but **only frees export**
   — it does not remove the supply/import declaration. Décret Annexe 1's exemptions are narrow and
   hardware-flavoured.

5. **Answering "No" to Apple is a genuinely low-risk holding position — but it does *not* by itself
   remove France from sale.** The obligation is triggered by *supplying in France*, so not distributing
   there means the duty is not engaged. **Critically: Apple's France answer only records intent; it
   does not change your territory list.** To actually not supply France you must separately remove it
   under *Pricing and Availability*. Right now our answer says "No" while France is, in all likelihood,
   still an enabled territory — an inconsistency worth closing. Nothing here is a global block, and it
   is reversible without a rebuild.

6. **But the enforcement risk is not zero and not merely theoretical.** In August 2024 French
   prosecutors charged Pavel Durov with, among other things, **exactly these two offences** — verbatim
   from the Tribunal de Paris release. That is the single most important fact in this document, and it
   is why the "everyone ignores it" framing is wrong.

**Practical recommendation for Channel Messenger:**

- **Now (beta):** keep "No". The position is sound — we are not supplying in France.
- **Check:** confirm France is actually removed under *Monetization > Pricing and Availability*.
  Answering "No" did **not** do this for us (§4.1). Do the same for Google Play, which never asks.
- **Before App Store release:** either file the declaration (a free form, ~1 month lead time) or
  consciously ship without France. Do not let the answer default.
- **Counsel:** worth it only for the narrower `prestation de cryptologie` question in §7 — not for the
  basic filing, which is a form anyone can submit.
- **Do not** treat "standard published algorithms" as an exemption. In French law that axis does not
  exist (§3.5), even though it is exactly the axis Apple and BIS use.

---

## How to read this

Every claim below is tagged:

- **[VERIFIED]** — I read the primary text myself (statute, decree, ANSSI page, official PDF).
- **[SECONDARY]** — reported by a credible source, not confirmed against a primary text.
- **[INFERRED]** — my reasoning from verified facts; the reasoning is shown so you can disagree.
- **[UNRESOLVED]** — sources conflict, or I could not establish it.

---

## 1. Is the French requirement still in force in 2026?

**Yes. [VERIFIED]** Nothing has repealed or relaxed it, and no EU instrument has taken it over.

### 1.1 The statute — LCEN art. 30, unmodified since 2004

Loi n° 2004-575 du 21 juin 2004 (LCEN), art. 30, verbatim:

> **I.** — L'utilisation des moyens de cryptologie est libre.
>
> **II.** — La fourniture, le transfert […] l'importation et l'exportation des moyens de cryptologie
> assurant **exclusivement** des fonctions d'authentification ou de contrôle d'intégrité sont libres.
>
> **III.** — La fourniture, le transfert depuis un Etat membre de la Communauté européenne ou
> l'importation d'un moyen de cryptologie **n'assurant pas exclusivement** des fonctions
> d'authentification ou de contrôle d'intégrité sont soumis à une **déclaration préalable** auprès du
> Premier ministre […] Le fournisseur […] tien[t] à la disposition du Premier ministre une description
> des caractéristiques techniques de ce moyen de cryptologie, **ainsi que le code source des logiciels
> utilisés**.

Legifrance shows art. 30 in force with **no modification since 22 June 2004**. **[VERIFIED]**

**Does Channel Messenger fall in III rather than II?** Yes, unambiguously. **[INFERRED, high confidence]**
Art. 29 defines a *moyen de cryptologie* as any hardware or software designed to transform data using
secret conventions, to ensure confidentiality, authentication or integrity. We do X25519 key agreement
and ChaCha20-Poly1305 authenticated encryption for **message confidentiality** — that is squarely
"not exclusively authentication or integrity." The art. 30 II free pass is for signature/MAC-only
products. It does not apply to an E2EE messenger.

Note the sting in III: the supplier must **hold the source code available** to the Prime Minister
(i.e. ANSSI) on request. It need not be *submitted* with the declaration, but it must exist and be
producible. For a closed-source Rust core this is a real, if low-probability, obligation.

### 1.2 The decree — 2007-663

- **Art. 1** — exemption from all prior formalities only for operations listed in **Annexe 1**.
- **Art. 3, 1°** — declaration required for "les opérations […] de fourniture, de transfert depuis un
  Etat membre […] et d'importation de moyens de cryptologie n'assurant pas exclusivement des fonctions
  d'authentification ou de contrôle d'intégrité". **[VERIFIED, read verbatim]**
- **Art. 4** — "**Un mois au moins avant l'opération** mentionnée à l'article 3, le dossier de
  déclaration est adressé […] à l'Agence nationale de la sécurité des systèmes d'information".
  **[VERIFIED]**
- **Art. 5** — if the dossier is incomplete ANSSI asks for more within one month, and the one-month
  clock **restarts** from receipt of the additional material. **[VERIFIED]**

### 1.3 ANSSI's own current pages — with an important internal conflict

**[UNRESOLVED — flagged, then resolved on the balance of evidence]**

ANSSI has two live pages that present the same summary table differently:

| Page | "Importation en France" | "Fourniture en France" |
|---|---|---|
| [Contrôle relatif à un moyen de cryptologie](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/) | *(cell empty)* | *(cell empty)* |
| [Démarches à accomplir](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/controle-rglementaire-cryptographie-demarches/) | **Déclaration auprès de l'ANSSI** | **Déclaration auprès de l'ANSSI** |

I checked the raw HTML of both. On the first page the *moyen de cryptologie* column is genuinely
**blank** for those two rows (the visible "/" belongs to the adjacent *double usage* column). It would
be easy to misread that page as saying supply and import in France now require nothing — an earlier
automated read of it did exactly that.

**The blank cells are a page-authoring defect, not a legal change.** Four things settle it: **[INFERRED,
high confidence]**

1. The prose on that same page says supply, import, intra-EU transfer and export "sont soumis, sauf
   exception, à déclaration ou à demande d'autorisation."
2. The same page describes the *attestation de déclaration* as the document that "permet de **fournir,
   importer en France** et transférer le moyen" — meaningless if neither required a declaration.
3. The sibling *Démarches* page states "Déclaration auprès de l'ANSSI" explicitly for both rows.
4. LCEN art. 30 III and décret art. 3 are unrepealed.

**Other defects on ANSSI's own pages, worth knowing before you rely on them:** **[VERIFIED]**

- The two form links are **swapped**. The link labelled "déclaration […] relative à un moyen de
  cryptologie" serves `crypto_form_fourniture_prestation_annexe2.pdf`, which I opened: it is
  **Annexe II — déclaration de fourniture d'une *prestation* de cryptologie**, the wrong form.
- The link that should serve **Annexe I** (the actual *moyen* form) **404s**.
- The "exception" link points at a dead legacy `ssi.gouv.fr` URL.
- One page says exports go to **8** "EU001" countries; the other says **7** and lists them (Australia,
  Canada, USA, Japan, New Zealand, Norway, Switzerland — the UK is absent). Post-Brexit the UK was
  added to EU001, so "7 + UK = 8" is the likely explanation, but the pages contradict each other.
  **[UNRESOLVED, minor]**

Practical consequence: **email `controle@ssi.gouv.fr` and ask for the current Annexe I form** rather
than trusting the site's links.

### 1.4 Has EU law superseded it? No. [VERIFIED / INFERRED]

- **Regulation (EU) 2021/821** (dual-use) controls **export and intra-EU transfer** of Category 5 Part 2
  "Information Security" items. ANSSI's own pages run the two regimes **in parallel** — its table has a
  separate column for "démarches liées au classement « double usage »". It does not touch supply
  *inside* France. **[VERIFIED]**
- Nothing in NIS2 or the Cyber Resilience Act addresses cryptology supply declarations. **[INFERRED]**
- A February 2026 French practitioner overview still describes the declaration regime as current.
  **[SECONDARY]**

I found **no** 2024–2026 reform, sunset, or relaxation. **[VERIFIED to the limits of searching]**

---

## 2. Who must file — the developer, not the store

**ANSSI: "Ces démarches incombent au fournisseur ou au primo-importateur du moyen de cryptologie et
sont à accomplir auprès de l'ANSSI."** **[VERIFIED]**

- **Each developer is independently responsible.** There is no filing by Apple or Google that
  discharges a third-party developer's obligation. Neither company claims to make one. **[VERIFIED
  that no such claim exists; INFERRED that none covers you]**
- **Foreign suppliers are included.** ANSSI's FAQ requires a KBis extract "**ou équivalent pour les
  sociétés étrangères**" — the dossier is explicitly built to accept non-French companies. **[VERIFIED]**
- **An individual can file.** The form has an "A-2. Particulier" branch, and Debian's real attestation
  was issued to a named individual, not a company (§5.2). **[VERIFIED]**
- **Apple is, in practice, the only enforcer.** An ANSSI official said publicly in 2017 that "Apple, en
  tant qu'**importateur**, demande au fournisseur de remplir une déclaration" — Apple pushes the duty
  *down* to developers rather than absorbing it. The same reporting concluded Apple has been the sole
  platform checking since 2013. **[SECONDARY]**
- **Google Play says nothing at all.** Google's export compliance page mentions France **zero times**,
  ANSSI zero times, and covers only US export law and embargoed countries. **[VERIFIED as an absence]**

**The asymmetry to internalise:** Apple's check and French legal liability are *different things*. The
duty under LCEN arts. 30–31 falls on the supplier or first importer **regardless of platform**. An
Android-only developer is in exactly the same legal position as an iOS one — nobody is merely policing
it. So "Google didn't ask" is not evidence that no obligation exists on the Play side. **[INFERRED,
high confidence]**

**[UNRESOLVED]** Whether Apple itself files a declaration covering *iOS and its own frameworks* is not
publicly documented. Even if it does, it would cover Apple's crypto, not our Rust core — which is
precisely why Apple asks whether you implement your own algorithms.

---

## 3. The process, timing, cost, and exemptions

### 3.1 What it is

A **declaration** (`déclaration`), not an authorisation. You notify; you do not wait for approval.
Distinct from the *export* authorisation (4-month statutory window) and from the "grand public"
classification decision (2 months). **[VERIFIED]**

### 3.2 How to file [VERIFIED]

Electronic, by email to **`controle@ssi.gouv.fr`**, subject line exactly:

```
[formalités] MARQUE – nom du produit
```

Attach: the saved electronic form, a signed scanned copy, and supporting documents (`.pdf`, `.xls`,
`.doc`). Postal filing to SGDSN/ANSSI, 51 boulevard de La Tour-Maubourg, 75700 Paris 07 SP remains
possible. Electronic submission has been available since 13 September 2022. **[SECONDARY for the date]**

**Dossier contents** (ANSSI FAQ): **[VERIFIED]**

- company presentation
- KBis extract < 3 months, **or foreign equivalent**
- commercial brochure
- technical description
- user guide and administrator guide, if they exist

Plus, on the form itself: generic designation in `MARQUE — NOM DU MOYEN` format, version, commercial
reference, and — the technical heart of it — **the algorithms used and the maximum key length for
each**, broken out by function (authentication, signature, confidentiality, key management…).

For us that is a short and genuinely easy list: X25519 (RFC 7748), Ed25519 (RFC 8032),
ChaCha20-Poly1305 (RFC 8439), HKDF-SHA256 (RFC 5869), SHA-256.

**Language:** the site and forms are French-only, and ANSSI's replies come in French. But this is less
of a barrier than the folklore suggests: **[SECONDARY]**

- ANSSI **holds a courtesy English translation of the form** for foreign filers, on request.
- Cryptomator (2016) filled the form in **English** and it was accepted.
- Conversely, ChatSecure's maintainer was blocked from the French App Store **for over three years**
  purely because of the French-language paperwork and international postage — the worst documented
  outcome, and it predates electronic filing.

**Postal filing is no longer required.** ANSSI dropped paper-only at the end of 2015 and email
submission has been available since at least 13 September 2022. Cryptomator's much-quoted 2016 line
that you must submit "via mail (yes, not email)" is **stale** — do not plan around it. **[VERIFIED
against ANSSI's current page; conflict with the 2016 blog noted]**

**Source code:** not required *with* the declaration, but must be **held available** for ANSSI on
request (LCEN art. 30 III). **[VERIFIED]**

### 3.3 Timing

- **Statutory:** file **at least one month before** supplying in France (décret art. 4). **[VERIFIED]**
- **Incomplete dossier** restarts the one-month clock (art. 5). **[VERIFIED]**
- **Real-world, dated anecdotes** — all old, treat with caution: **[SECONDARY]**
  - Cryptomator (2016): ~2 months from filing to approval.
  - Wire: "weeks to a month", and they **pulled Wire from the French App Store while waiting**.
  - ProtonMail: iOS launch slipped about a month.
  - Status (2018): GitHub issue opened 4 May, closed "Submitted!" 4 June.
  - ChatSecure: blocked from the French App Store for 3+ years — the worst reported outcome.

### 3.4 Cost

**No fee is mentioned anywhere** — not in the decree, the 2015 arrêté, or any ANSSI page. **[VERIFIED
as an absence]** The cost is preparation time, and translation if you do not write French.

Do not confuse this with **CSPN/Common Criteria certification**, which is voluntary, published, and
genuinely expensive. The declaration is neither an evaluation nor a quality judgement — the Debian
attestation says so in terms: *"La présente attestation ne constitue en aucun cas une indication sur la
qualité de ce moyen de cryptologie ou une recommandation."* **[VERIFIED]**

### 3.5 Exemptions — none that plainly covers us

**There is no "standard published algorithms" exemption.** **[VERIFIED as an absence]** Nothing in LCEN
art. 30, décret 2007-663, or the 2015 arrêté conditions the duty on algorithm novelty. That axis is
Apple's and BIS's, not France's. France asks *what the product does* (confidentiality → declare), not
*whether you invented the primitive*.

**"Grand public" / mass-market does not exempt you from the supply declaration.** This is the most
commonly misunderstood point. **[VERIFIED]** ANSSI: *"Les moyens de cryptologie « grand public »
s'exportent librement, sans autorisation d'exportation de l'ANSSI ni licence du SBDU."* — it is an
**export** freedom. It is *requested at the time of the declaration*, which presupposes that you
declare. The three conditions (décret Annexe 2, point 3) are the familiar Wassenaar Cryptography Note:

> a) sont couramment à la disposition du public en étant vendus directement sur stock, sans
> restriction, à des points de vente au détail […]
> b) la fonctionnalité cryptographique ne peut pas être modifiée facilement par l'utilisateur
> c) sont conçus pour être installés par l'utilisateur sans assistance ultérieure importante de la part
> du fournisseur

Channel Messenger, distributed free through the App Store and Play Store, meets all three comfortably.
**[INFERRED, high confidence]** So we would likely obtain "grand public" classification — valuable for
export, irrelevant to whether we must declare.

**Annexe 1's exemptions do not reach us.** **[SECONDARY — see caveat]** The ~15 exempt categories are
narrow and mostly hardware: smartcards, broadcast receivers, banking equipment, mobile radio, cordless
phones (≤400 m), copyright protection, 802.11/802.15 equipment, system administration tools, personal
development means, weak-key algorithms. None is a general-purpose consumer messaging application.

*Caveat:* Legifrance is behind a Cloudflare challenge I did not bypass, so **I read Annexe 1 only in
summarised form, not verbatim.** The official decree PDF mirror I obtained (New Caledonia juridoc) omits
the annexes. **Before relying on "no exemption applies", read Annexe 1 in full on Legifrance.** My
confidence that nothing covers a messaging app is high but not primary-source-verified.

### 3.6 A documented failure mode: ANSSI may say "out of scope" and issue nothing

**[SECONDARY, but attested twice independently]** At least two developers who filed in 2020 were told by
ANSSI that their app fell outside the regime and that **no document would be issued at all**. ANSSI's
reply to one, verbatim:

> "Please be informed that the mobile application […] is out of the scope of both domestic (decree
> n°2007-663) & european (Regulation n°2019/2199) regulations. Consequently, **we will not be issuing
> any document**. You may market the aforementioned product without any restriction."

A second developer reported the identical outcome on Apple's forums. Both were then stuck: App Store
Connect still presents a document-upload field, and **Apple publishes no guidance on what to upload
when ANSSI declines to issue anything.** This is a genuine, undocumented gap.

**Does it apply to us? Almost certainly not.** **[INFERRED]** The reported case involved weak, local-only
storage encryption (DES-56), which plausibly falls in Annexe 1's weak-algorithm category. An E2EE
messenger doing X25519 + ChaCha20-Poly1305 for message confidentiality is the paradigm case *inside*
the regime, not outside it. Do not plan on being told we are out of scope.

There is one adjacent and more encouraging data point: Apple's export compliance team reportedly
resolved a 2024 case by simply **waiting out ANSSI's one-month window** rather than demanding an
approval document — *"We had to wait until the ANSSI submission timeframe of one month had passed."*
That matches Apple's softened wording (§4.5) but rests on a single support interaction. **[SECONDARY,
weak]**

---

## 4. Answering "No" to Apple vs "Yes" and filing

### 4.1 What "No" actually does — and what it does *not* do

Apple's official documentation states: **"French encryption declaration form is only required if you're
distributing your app on the App Store in France."** **[VERIFIED]**

Apple's overview page also describes what France controls, which is worth reading closely:

> "The import and export of encryption apps distributed in France are also controlled by the French
> Government. The main items of control for France are **Secure Storage, Secure Communications**, and
> Security Anti-Virus applications. Exemptions include Banking and Medical applications." **[VERIFIED]**

"Secure Communications" is exactly what Channel Messenger is, and neither listed exemption applies to
us. Apple's own framing puts us squarely in scope.

**The single most important mechanical fact: answering "No" records an intent flag. It does not remove
France from your territories.** **[VERIFIED]**

Apple's App Store Connect API defines the field as:

> **`availableOnFrenchStore`** — "A Boolean value that indicates **the intent** to distribute your app
> on the French App Store."

It is `required: true` on declaration creation, and there is **no documented link** anywhere in the API
between `AppEncryptionDeclaration` and `Territory` or app availability. Third-party integration
documentation makes the separation explicit:

> "**Important** — If your app is not going to be available for distribution in France, then it is
> important to **remove France from App Store Connect**. To do this, go to Monetization > Pricing and
> Availability" **[VERIFIED — Signicat iOS SDK docs, © 2026]**

**Consequence for us:** answering "No" while France remains an enabled territory is an internal
inconsistency that Apple's systems do not auto-reconcile. It is fine for a closed TestFlight beta, but
**before any App Store release, France must be removed under Pricing and Availability if we intend the
"No" to be true.** Otherwise we would be answering "not distributing in France" while shipping there.

**Other mechanics:** **[VERIFIED]**

- **It does not block the build.** What blocks is a required-but-missing declaration document. Answering
  "No" makes the France document not required, so the declaration completes. Declaration states are
  `CREATED`, `IN_REVIEW`, `APPROVED`, `REJECTED`, `INVALID`, `EXPIRED`.
- **France only.** No other EU country appears anywhere in the flow or API.
- **Changeable later without a rebuild.** There is no PATCH endpoint for declaration attributes; the
  documented path is *create a new declaration* and then *assign existing builds to it*. So reversing
  the France answer does **not** require a new binary.
- Per `EXPORT-COMPLIANCE.md` §3b, answering per build in the TestFlight UI is the working path for this
  app; the Info.plist route produced upload failures.

**[UNRESOLVED]**

- **TestFlight testers in France.** Export compliance applies to beta builds, and the France answer is
  part of the same app-level declaration. But no source — Apple or otherwise — indicates that answering
  "No" restricts *TestFlight tester* access in France. TestFlight is governed by invitations and public
  links rather than storefront territory, so I would expect no effect, but that is inference. Verify
  before relying on it if we recruit French testers.
- **French overseas territories.** An empirical probe of Apple's search API found only `FR` returns a
  storefront (NC, PF, RE, GP, MQ, GF, YT, WF, PM, BL, MF returned nothing), consistent with DOM-TOM
  being served by the France storefront. This is an API probe, **not** Apple's published territory list.

### 4.2 The real risk of "No"

**Low, and structurally sound rather than merely tolerated.** **[INFERRED, high confidence]**

The obligation in LCEN art. 30 III attaches to *la fourniture […] ou l'importation* — supplying or
importing **in France**. If the app is genuinely not available in France, we are not supplying there,
so the duty is not triggered. This is not evasion of an applicable rule; it is not meeting the rule's
trigger. The cost is commercial (no French users), not legal.

The load-bearing word is *genuinely*: this reasoning holds only if France is actually removed from
availability, which the Apple answer alone does not do (§4.1). "No" on the form plus a live French
listing is the one combination that gets the worst of both.

Two honest caveats:

1. **"Fourniture" may be broader than "the French storefront."** If French residents can obtain and use
   the app by other routes — a French-language website, direct APK distribution, a web client, or an
   Android build available in France while iOS is not — the "we don't supply in France" position gets
   weaker. **[INFERRED]** Note that Apple's question governs *Apple's* storefront only; **it does not
   answer for Google Play or for any direct distribution we do.** If Channel Messenger ships on Play in
   France while answering "No" to Apple, the legal position is inconsistent.
2. **Mere accessibility is not obviously "supply", but the line is untested here.** **[UNRESOLVED]**

### 4.3 The risk of distributing in France *without* declaring

This is the branch that actually carries teeth, and it is worse than the folklore suggests.

**Criminal penalties, LCEN art. 35 [VERIFIED verbatim]:**

- Failing to declare supply/transfer/import/export of a *moyen*: **1 year imprisonment and €15,000**.
- Export/EU transfer without required authorisation: **2 years and €30,000**.
- Supplying *prestations de cryptologie* for confidentiality without declaring (art. 31): **2 years and
  €30,000**.

**Administrative sanction, LCEN art. 34 [VERIFIED]:** the Prime Minister may prohibit circulation of the
means **throughout France**, and compel **withdrawal** from commercial distributors — expressly
applicable to a supplier acting **"même à titre gratuit"** (even free of charge). Free distribution is
no shield.

**And it has actually been charged.** The Tribunal de Paris press release of 28 August 2024 on Pavel
Durov's *mise en examen* lists, verbatim (I extracted this from the official PDF myself): **[VERIFIED]**

> - Fourniture de prestations de cryptologie visant à assurer des fonctions de confidentialité sans
>   déclaration conforme
> - Fourniture et importation d'un moyen de cryptologie n'assurant pas exclusivement des fonctions
>   d'authentification ou de contrôle d'intégrité sans déclaration préalable

Those are **both** of the obligations discussed in this document, charged criminally against the
operator of a mass-market encrypted messenger. It was not the only or the most serious charge, and
prosecutors plainly reached for everything available — but the "nobody enforces this" assumption died
in August 2024, and it should not be relied on.

### 4.4 The risk of "Yes" and filing

Low, and mostly cost-of-time. **[INFERRED]** The declaration is not an approval gate, there is no fee,
and the "grand public" classification we would likely receive is a benefit for export. The realistic
downsides are: preparing a French-language technical description; the standing obligation to hold
source code available for ANSSI; and — on the dated anecdotes — the possibility of a delay during which
Apple withholds French availability (Wire pulled its app while waiting).

**On balance:** filing is a form, not a legal battle. The reason to defer is that we are in beta and
not yet supplying anywhere publicly — not that filing is hard.

### 4.5 Does Apple want a *filed* declaration or an *approved* one? [UNRESOLVED]

This matters for scheduling a French launch, and Apple has never documented the change:

- **2013** — Apple's export compliance email said: "Apple will require you to upload a copy of your
  **approved** French declaration." **[VERIFIED, archived]**
- **2016** — Cryptomator likewise describes needing "approval from the ANSSI." **[SECONDARY]**
- **Today** — Apple's live reference page says only "Upload your **French encryption declaration**."
  The word "approved" is gone. **[VERIFIED]**
- **2024** — one developer's support thread suggests Apple accepts the filing and waits out ANSSI's
  one-month window. **[SECONDARY, single report]**

**Reading:** the trend favours "filed, plus the statutory month" rather than "approved", which is also
what the law implies — art. 30 III creates a *declaration*, and there is no approval to wait for. But
Apple has not said so, so budget for the possibility that a French launch slips by roughly a month
after filing. The declaration is **also not deprecated**: Apple's API still exposes
`AppEncryptionDeclarationDocument` and its upload endpoints, and `availableOnFrenchStore` is **not**
among the deprecated fields. Claims that "Apple removed the French requirement" are false — what
actually happened is that most developers now set `ITSAppUsesNonExemptEncryption = NO` and never see
the flow. We cannot: we ship our own crypto. **[VERIFIED]**

---

## 5. What comparable apps do

### 5.1 They all ship in France [VERIFIED]

Signal, WhatsApp, Threema, Wire, Telegram, Element, and Session are **all currently available on both
the French App Store and Google Play France**. None is geo-restricted out of France.

Apple's evidence is conclusive: `itunes.apple.com/lookup?country=fr` is a true per-storefront query and
all seven returned `resultCount=1` with French-localised names and pricing (Signal → "Signal -
Messagerie privée"; Threema → **7,99 €**). Google Play evidence is strong but one notch weaker: all
seven return HTTP 200 on FR-locale pages with no unavailability notice, though only Threema (**6,49 €**)
gives currency-level proof. **[VERIFIED]**

**Note the tension this creates with §4.** These apps are all shipping in France, and at least Wire,
ProtonMail, Cryptomator and Status are on record as having filed. Whichever of the seven did *not* file
is invisible to us — see §5.2.

### 5.2 There is no public record of what any of them filed [VERIFIED]

**ANSSI does not publish cryptology declarations.** This is confirmed about as strongly as it can be: a
French freedom-of-information request (Ma Dada, Aug–Oct 2024) asked ANSSI for exactly the *"Liste des
moyens de cryptologie déclarés auprès de l'ANSSI"* and met an **implicit refusal** — silence past the
statutory deadline. CADA was seized and had still issued no opinion as of September 2026.
<https://madada.fr/demande/liste_des_moyens_de_cryptologie>

The regime is structurally **bilateral**: ANSSI issues the *attestation de déclaration* to the
declarant, and it is the **supplier** who must make copies available to customs and exporters. Nothing
is published.

**Critical distinction — do not confuse these two things:**

| | Published? | Nature |
|---|---|---|
| **Certification** (CSPN, Common Criteria) | **Yes**, exhaustively — ANSSI publishes a monthly catalogue and per-product reports | Voluntary, evaluated, expensive |
| **Déclaration d'un moyen de cryptologie** | **No** — nothing published, FOIA refused | Mandatory, administrative, free |

So: seeing an app in ANSSI's published catalogue tells you it *paid for certification*, not that it
declared. And the absence of any public list means **you cannot verify whether Signal or WhatsApp
filed** — that is not publicly knowable. **[VERIFIED as unknowable]**

The one real public example is **Debian**, which self-published its own attestation because no registry
exists: dossier **no. 1101027**, 20 January 2011, issued to Yves-Alexis Perez for Debian 5.0 (Lenny),
classified **catégorie 3** (grand public). <https://www.debian.org/legal/anssi.fr.html> **[VERIFIED]**
It shows an individual maintainer of a free OS completing the process — evidence this is not a
corporate-only undertaking. It is also 15 years old.

### 5.3 Company statements [SECONDARY]

None of Signal, Threema, Wire, Element, or Session has publicly discussed an ANSSI filing — searches of
Signal's blog, GitHub org, and community forum returned zero hits for ANSSI; Threema's legal-compliance
FAQ never mentions France. The substantive accounts come from *adjacent* projects, mostly 2016–18, via
Next INpact's 2017 article *"Les outils de chiffrement face à la déclaration à l'ANSSI, une exception
française"*: Wire (removed from the French App Store while waiting), ProtonMail (filed at Apple's
request; called it intrusive government interference), Cryptomator, ChatSecure, Status, Dashlane.

Apple, Meta and Google all declined to comment in 2017, and ANSSI cancelled its own scheduled
interview. Two Apple Developer Forums threads on the French declaration have **no Apple staff reply at
all**. The information environment here is genuinely poor.

### 5.4 French context, briefly [SECONDARY unless noted]

- **Olvid**, a French E2EE messenger, is CSPN-certified and was **mandated for French government
  officials** by a November 2023 circular, displacing WhatsApp/Signal/Telegram on ministers' phones.
  Its visibility comes from *certification*, not declaration — reinforcing §5.2's distinction.
- **France has not banned or backdoored E2EE.** The *narcotrafic* bill's article 8 ter would have forced
  encrypted messengers to enable intelligence access; it was **rejected on the floor 119–24 on 20–21
  March 2025**, and the final [LOI n° 2025-532 du 13 juin 2025](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000051734851)
  contains no such provision. Meredith Whittaker had threatened Signal's exit from France. Context only
  — unrelated to the declaration duty, but it is the reason France/E2EE searches are full of noise.

---

## 6. Where sources conflict or may be out of date

| Issue | Status |
|---|---|
| ANSSI's two summary tables disagree on whether supply/import in France requires a declaration | **Resolved** in favour of "declaration required" (§1.3), but be aware the first page reads otherwise |
| ANSSI's form links are swapped; the Annexe I form 404s; the "exception" link is dead | **Verified broken.** Email `controle@ssi.gouv.fr` for the current form |
| EU001 country count: 8 on one ANSSI page, 7 on another | **Unresolved**, minor; likely the UK |
| Annexe 1 exemption list read in summary only, not verbatim | **Open** — verify on Legifrance before relying on "no exemption applies" |
| Processing-time anecdotes are all 2016–2018 | **Stale.** Treat as weak evidence for 2026 |
| Apple's flow has changed repeatedly over the years | Current wording verified; historical behaviour varies. See `EXPORT-COMPLIANCE.md` §3b |
| TestFlight/DOM-TOM behaviour when answering "No" | **Unresolved** (§4.1) |
| Whether Apple needs a *filed* or *ANSSI-approved* declaration | **Unresolved** (§4.5); budget ~1 month either way |
| Cryptomator's "postal only" (2016) vs ANSSI's electronic filing | **Resolved** — electronic since end-2015; the blog is stale |
| Exact 2026 App Store Connect UI wording for the France question | Apple never publishes it. Two attested variants (2016, ~2024); the API field semantics match our recollection |
| Whether major messengers actually filed | **Not publicly knowable** (§5.2) |
| ANSSI may declare an app "out of scope" and issue no document | **Real, attested twice** (§3.6); Apple has no guidance for that case |

---

## 7. Lawyer, or form anyone can file?

**A form anyone can file:**
- The declaration of a *moyen de cryptologie* itself. Short form, no fee, no approval gate, foreign
  companies and individuals explicitly accommodated. Debian's maintainer did it. The hardest parts are
  writing the technical description in French and assembling a KBis equivalent.

**Where French counsel is genuinely worth it:**

1. **The `prestation de cryptologie` question (art. 31) — the real open issue.** Art. 29 defines a
   *prestation* as "toute opération visant à la mise en œuvre, **pour le compte d'autrui**, de moyens de
   cryptologie", and art. 31 requires its own declaration, carrying the **heavier** penalty (2 years,
   €30,000). In a true E2EE design, keys live on the client and the server relays ciphertext, so the
   natural reading is that we supply a *moyen* and do **not** provide a *prestation*. **But French
   prosecutors charged Telegram's founder with exactly that** (§4.3). Whether operating Channel
   Messenger's servers constitutes a *prestation* is a genuine legal question with a criminal penalty
   attached, and it is the one question I would not answer from a website. **[UNRESOLVED — get advice]**
2. Whether our overall distribution footprint (website, Play Store, any web client) amounts to
   *fourniture en France* even with the French App Store storefront switched off (§4.2).
3. Confirming no Annexe 1 exemption applies, from the verbatim current text.

**Not worth a lawyer:** deciding whether standard published algorithms exempt us. They do not — that
axis does not exist in French law (§3.5).

---

## 8. Sources

**Primary — statute and regulation**
- Loi n° 2004-575 du 21 juin 2004 (LCEN), arts. 29–36 — [Legifrance art. 30](https://www.legifrance.gouv.fr/loda/article_lc/LEGIARTI000006421577/); full text read via [mirror](https://www.marche-public.fr/Marches-publics/Textes/Lois/LCEN/loi-2004-575-LEN.htm)
- Décret n° 2007-663 du 2 mai 2007 — [Legifrance](https://www.legifrance.gouv.fr/loda/id/JORFTEXT000000646995/); arts. 1–13 read verbatim from the [official juridoc PDF mirror](https://mobi-juridoc.gouv.nc/juridoc/jdtextes.nsf/85DAFE301032F06C4B257D6D00012364/$file/decret_2007-663_du_02-05-2007_CG.pdf)
- Annexe 1 (exemptions) — [Legifrance](https://www.legifrance.gouv.fr/codes/article_lc/LEGIARTI000006428332/) *(read in summary only)*
- Annexe 2 (incl. "grand public" conditions) — [Legifrance](https://www.legifrance.gouv.fr/loda/article_lc/LEGIARTI000006428333/)
- Arrêté du 29 janvier 2015 (dossier form and content) — [Legifrance](https://www.legifrance.gouv.fr/loda/id/JORFTEXT000030255024/)
- Règlement (UE) 2021/821, Cat. 5 Part 2 — referenced by ANSSI's export page

**Primary — ANSSI**
- [Contrôle relatif à un moyen de cryptologie](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/)
- [Démarches à accomplir](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/controle-rglementaire-cryptographie-demarches/)
- [FAQ — demande d'autorisation](https://cyber.gouv.fr/faq-demande-dautorisation)
- [Contrôle export](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-export/)

**Primary — other official**
- Tribunal de Paris, communiqué de presse, 28 Aug 2024 (Durov *mise en examen*) — [PDF](https://www.tribunal-de-paris.justice.fr/sites/default/files/2024-08/2024-08-28%20-%20CP%20TELEGRAM%20mise%20en%20examen.pdf) *(text extracted and quoted verbatim)*
- [LOI n° 2025-532 du 13 juin 2025](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000051734851) (narcotrafic; no E2EE provision)
- [Ma Dada FOIA request for the list of declared cryptology means](https://madada.fr/demande/liste_des_moyens_de_cryptologie) — refused

**Primary — Apple** *(all fetched live 2 Sep 2026, © 2026 Apple Inc.)*
- [Export compliance documentation for encryption](https://developer.apple.com/help/app-store-connect/reference/app-information/export-compliance-documentation-for-encryption/) — the "French encryption declaration" table
- [Overview of export compliance](https://developer.apple.com/help/app-store-connect/manage-app-information/overview-of-export-compliance/) — the "Secure Storage, Secure Communications" France paragraph
- [Provide export compliance information for beta builds](https://developer.apple.com/help/app-store-connect/test-a-beta-version/provide-export-compliance-information-for-beta-builds/)
- [Complying with Encryption Export Regulations](https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations) — note: mentions France **zero** times, the likely source of the "Apple removed it" folklore
- [App Store Connect API — `AppEncryptionDeclaration.Attributes`](https://developer.apple.com/documentation/appstoreconnectapi/appencryptiondeclaration/attributes-data.dictionary) — the `availableOnFrenchStore` "intent" definition
- [App Store Connect API — App Encryption Declarations endpoints](https://developer.apple.com/documentation/appstoreconnectapi/app-encryption-declarations)
- [Apple Export Compliance email, 2013](https://gist.github.com/chrisballinger/7239932) — the older "**approved** French declaration" wording
- [Google Play — Export compliance](https://support.google.com/googleplay/android-developer/answer/113770) — mentions France zero times
- [Signicat iOS SDK — Apple export compliance requirements](https://developer.signicat.com/docs/mobile-identity/encap/sdk-ios/publish-your-app/apple-export-compliance-requirements/) — the "remove France from App Store Connect" instruction

**Secondary**
- [Debian — Attestation de déclaration d'un moyen de cryptologie](https://www.debian.org/legal/anssi.fr.html) (real 2011 attestation)
- Next INpact, *"Les outils de chiffrement face à la déclaration à l'ANSSI, une exception française"*, 13 Mar 2017 — <https://next.ink/10408/103575-les-outils-chiffrement-face-a-declaration-a-anssi-exception-francaise/>
- [Cryptomator, "In-Depth: Export Compliance for French iOS App Store"](https://cryptomator.org/blog/2016/06/16/indepth-french-app-store/) (2016)
- [Chris Ballinger gist, French encryption import compliance](https://gist.github.com/chrisballinger/7239932) (2013, comments through 2026)
- [status-im/status-legacy#4109](https://github.com/status-im/status-legacy/issues/4109) (2018 filing, tracked openly)
- Domanski Avocat, *Déclaration à l'ANSSI d'une application mobile intégrant un outil de chiffrement* (2022)

**Deliberately excluded:** a widely-circulated gist promoting a paid "declaration generation" service,
whose claim that HTTPS/APNs/Keychain use alone triggers the requirement is contested and which is
marketing, not community experience.


---

<!-- store/data-safety.md -->

# Data safety (store questionnaire)

# Google Play Data Safety form — answers for Channel Messenger

Written so whoever fills in the live Play Console form does not have to re-derive anything from
`DECISIONS.md`, `server/README.md`, or the app's permission requests. **Play Console's own wording
and category list change periodically — cross-check each answer against the live form before
submitting, rather than transcribing this blind.** Where a category has more than one plausible
answer, both are given with the reasoning, because this is a judgment call and not a lookup.

Effective as of the current design: no accounts, no analytics, contacts read only in memory when the user opens that screen and never collected (adding
someone from the address book goes through the system picker, which the app cannot read), no location
access, direct radio delivery with no server, and an optional internet fallback relay with optional
push notifications. If any of that changes, this document is stale — update it alongside the code
change, not after.

---

## Section 1 — Data collection and security

**Does your app collect or share any of the required user data types?**

Answer: **Yes**, conditionally — one data type only, and only if the user opts into push
notifications. See "Device or other IDs" below. If push notifications are removed or shipped
disabled at launch, the honest answer becomes **No** and this whole form simplifies to "no data
collected."

**Is all the user data collected by your app encrypted in transit?**

Answer: **Yes.** All traffic to the internet fallback relay is over HTTPS/TLS. Message content
carries a second, independent layer of end-to-end encryption underneath the transport encryption —
the relay's own transport connection being encrypted does not mean the relay can read the message;
it cannot, under any circumstance, because it does not hold the keys.

**Do you provide a way for users to request that their data be deleted?**

Answer: **Yes, in the sense that applies here — there is no account-deletion flow because there is
no account.** There is nothing tied to a user's identity for us to delete on request, because
nothing we hold is tied to an identity in the first place (no name, email, phone number, or account
ID exists anywhere in the system). What a user can do:
- Delete the app, or clear its local data, to remove everything stored on their own device (§7 of
  `privacy-policy.md`).
- Any message sitting in the relay unclaimed expires automatically within, at most, 30 days
  (`server/README.md`, `DECISIONS.md` J6) — there is no user request needed for it to be removed.
- A user who opted into push notifications can turn push off, which unregisters (deletes) their
  registration entry rather than merely silencing it (`server/README.md`, endpoint
  `DELETE /v1/register?id=`).

If Play's form requires a formal "request deletion" mechanism (e.g., a support contact or in-app
control) regardless of whether an account exists, point it at the contact address in
`privacy-policy.md` §13 and note that requests will typically already be moot, since nothing
identity-linked is retained.

**Independent security review (optional, e.g. MASA badge)**

Not yet done as of this writing. Leave unanswered / "No" until (if) `mesh-core-rs` and the relay
undergo one. Not a blocker for submission.

---

## Section 2 — Data types

Go through Play's categories in order. Anything not listed below is **not collected.**

### Location
**Not collected.** No location permission is requested; nothing in the app reads location.

### Personal info (name, email, phone number, address, etc.)
**Not collected.** There is no account system and no field anywhere in the app or the relay that
holds a name, email address, phone number, or physical address.

### Financial info
**Not collected by the app.** If in-app purchase is added later (pricing is not yet settled —
`DECISIONS.md` J4), purchase transactions go through Google Play Billing directly; per the F1
design decision, no purchase, receipt, or subscription state is ever passed into the app's own data
model or onto the wire. Google's own handling of Play Billing data is covered by Google's policies,
not something this app collects independently. Re-confirm this section once a monetization model is
implemented — if the app itself ever reads or stores purchase state, this answer must change.

### Health and fitness
**Not collected.** Not applicable to this app.

### Messages
**Not collected, in the sense Play's form asks about.** Message content transits the internet
fallback relay only as end-to-end encrypted ciphertext the relay cannot decrypt; the developer never
has access to plaintext message content at any point. Google Play's Data Safety guidance
distinguishes data that is merely relayed and inaccessible to the developer (not collected) from
data the developer can actually access (collected) — **verify this distinction's current wording in
Play Console's help center before answering**, since it is the basis for this answer and policy
text has been known to shift. If in doubt, disclose "Messages" as collected-but-not-accessible
rather than omitting it, and use the form's free-text explanation field to state plainly that
content is end-to-end encrypted and the developer cannot read it.

### Photos and videos
**Not collected.** The app does not access the photo library. Camera access exists only for
scanning a QR code during pairing (§9 of `privacy-policy.md`); no photo or video is captured, stored,
or transmitted.

### Audio files
**Not collected.** No microphone access; voice messages are not a feature.

### Files and docs
**Not collected.** The app is text-only (`DECISIONS.md` C7); there is no file attachment feature.

### Calendar
**Not collected.**

### Contacts
**Not collected**, and that answer is unchanged by the Phone Contacts screen — Play's question is
about data **leaving the device**, and nothing does.

The app *may now read* the address book, with permission, and only when the user opens that screen.
It is held in memory to draw the list and is never written to app storage, indexed, hashed, or
transmitted. Declining the permission disables that one screen and nothing else; the system contact
picker route still needs no permission at all (`DECISIONS.md` A2, G1, J38, J50).

**If Play's form asks whether the app *accesses* contacts as distinct from collecting them, answer
yes.** Answering "no permission is requested" was true before the browse screen existed and is not
true now.

A user may add someone *from* their address book, using the platform's **system contact picker**
(iOS `CNContactPickerViewController`). That picker runs outside the app: it renders the address book
itself and returns only the entry the user taps. Apple states that an app using it "does not need
access to the user's contacts and the user will not be prompted for 'grant permission' access."

What the app receives is **one display name**, kept on the device, identical to what the user could
have typed into "Add by Name". No phone number, email, photo or address-book identifier is read, and
nothing derived from the address book is transmitted off the device — so this stays *Not collected*
under Play's definition (data is "collected" when it leaves the device). It is also not *shared*.

If a future version ever reads the address book directly, requests the contacts permission, or sends
anything derived from it anywhere, this answer changes and so does `privacy-policy.md` §2 and §9 —
in the same release, not after.

### App activity (app interactions, in-app search history, installed apps, other user-generated
content, other actions)
**Not collected.** No analytics SDK of any kind is included (`DECISIONS.md` A2). Nothing about how
the user interacts with the app is recorded or transmitted.

### Web browsing
**Not collected.** Not applicable.

### App info and performance (crash logs, diagnostics, other performance data)
**Not collected.** No crash-reporting SDK is included. If the app crashes, the developer does not
learn about it unless the user reports it directly.

### Device or other IDs — the one conditional "Yes"
**Collected only if the user opts into push notifications**, which are off by default
(`DECISIONS.md` J6, J9, J10; `privacy-policy.md` §6). When enabled, the app registers a
device-generated identifier (`notifyId`) with the internet relay so it can be woken via Google's
push service (or Apple's, on iOS). This identifier:
- Is not a persistent hardware identifier (not IMEI, not a serial number, not an advertising ID —
  none of those are read or used).
- Is generated by the app itself, not derived from anything that identifies the user personally.
- Is disclosed here because, being stable across sessions rather than rotating, it functions as a
  durable pseudonym at the relay for as long as push stays enabled (this is explained candidly to
  users in `privacy-policy.md` §6, not something to soften in this form).

**Purpose:** App functionality (to enable optional push wake-ups). Not used for advertising,
personalization, or analytics of any kind.

**Is it shared with third parties?** It is provided to Google's push notification infrastructure
(Firebase Cloud Messaging on Android) purely as the transport mechanism required to deliver a
notification — this is standard platform plumbing common to essentially every Android app that
offers push notifications, not a data sale or an advertising integration. Play's current guidance on
what counts as "sharing" versus "service provider processing" for platform-required push delivery
should be checked against the live form; if Play's categorization requires disclosing this as
third-party sharing, disclose it, with purpose "App functionality" and explicitly marked not for
advertising or marketing.

**Is this data type required or optional, and can users opt out?** Optional. Users who never enable
push notifications never have this identifier collected at all, and users who disable push after
enabling it have their registration deleted, not merely deactivated (`DELETE /v1/register?id=`).

---

## Section 3 — Security practices

**Data is encrypted in transit:** Yes (TLS to the relay, plus independent end-to-end encryption of
message content underneath it).

**Data is encrypted at rest:** The relay stores ciphertext it cannot decrypt; there is no
plaintext at rest anywhere on the relay. On-device, message history and identity keys are stored
using the device's own file-protection mechanisms and are excluded from device backups (§7 of
`privacy-policy.md`; iOS: `completeFileProtectionUntilFirstUserAuthentication` plus backup
exclusion; Android: `android:allowBackup="false"`).

**Users can request data deletion:** See Section 1 above — there is no account to delete, but every
practical form of "delete my data" is already available (uninstall/clear local data; automatic
relay expiry within 30 days at most; push de-registration deletes rather than disables).

---

## Summary for whoever fills in the live form

If pressed for the one-line version: **this app collects nothing identifying, with a single
narrow exception — an opt-in, off-by-default push-notification identifier, disclosed above with its
exact purpose and its one real privacy cost stated plainly.** Every other Play Data Safety category
is "not collected," and that is a description of the architecture (`DECISIONS.md` section A, F1,
F2), not a policy choice made for this form.


---

<!-- DECISIONS.md -->

# Decisions log

# DECISIONS.md — settled design decisions

Every decision here was made by Don in conversation on 2026-09-01, after `SPEC-ORIGINAL.md` and
`SPEC-REVISION.md` were written. **Where this file conflicts with either spec, this file wins.**

Each entry records the decision, the reason, and what it costs — because the reasons matter more
than the rulings when someone revisits these in six months.

Status key: **Settled** · **Defaulted** (chosen by Claude, Don did not object, reversible on request)
· **Open** (needs Don).

---

## A. Infrastructure

### A1. No server for transport. — *Settled, then partially reversed by A6*
No relay in the *message path*, no bootstrap node, no directory.

> **Amended 2026-09-01.** This entry originally read "No server. Ever." `A6` reverses it for an
> opt-in encrypted mailbox and notification service, on the ground that the no-server design served
> proximity delivery and left everyone else without a working product. Read A1 for the reasoning
> that still holds — minimise what exists to be compelled — and `A6` for what was traded and why.
> `A7` draws the line that is *not* negotiable: the server never touches key material.

**Why.** Don: *"I don't want there to be an intermediate server for any of this because that requires
logging, and logging can be subpoenaed."* Regulatory duties attach to entities that operate a
service; the way to be a poor target is not to be an operator. "There is no we in the path" is a far
stronger position than "we don't retain it."

**Strikes:** SPEC-ORIGINAL §8 (InternetTransport), §33 (relay server), §34 (APNs wake-hints), and the
two Internet entries in the §1 transport order.

**Costs.** The mesh is physically local, permanently — internet peer connections need NAT traversal,
NAT traversal needs STUN, STUN is a server. No remote push. No cross-network delivery except via a
user-owned reachable node (A3).

### A2. No advertising, analytics, attribution, or crash-reporting SDKs. — *Settled*
Don: *"no advertising hooks or other things that have killed whatsapp trust."*

Includes, specifically:
- **Never request contacts permission.** QR pairing means there is no reason to read an address book.
  This is user-verifiable, unlike a promise.
- **No crash-reporting SDK.** This is the one added reflexively; it ships stack traces containing
  memory contents off-device. Local crash logs the user may export manually instead.
- No IDFA, no ATT prompt, no third-party SDKs in the core.
- App Store privacy label must read **Data Not Collected** in every category.

### A3. Optional user-operated persistent node (macOS/Windows). — *Settled*
An optional desktop app that is both a client and the user's own always-on relay/mailbox.

**Why.** It closes the only delivery gap that otherwise needs a server (see A4) without us operating
anything. The LAN case is the mainstream win: Bonjour discovery, zero configuration, household
devices sync whenever they are on the same network.

**Constraints:**
- **LaunchAgent, not LaunchDaemon.** Runs as the logged-in user, no admin rights, no root process
  parsing hostile network input. Register via `SMAppService` so it is visible and removable in the
  OS's own Login Items UI. Windows: per-user startup or logon task, never a Service.
- **Opt-in, never during onboarding, and uninstalling the app must remove the agent.** Background
  persistence is malware-shaped behaviour; this app cannot afford to look like that.
- **The persistent agent holds no identity keys.** It is a separate process that stores ciphertext,
  relays, and answers inventory queries — all of which work without decrypting anything. The UI app
  holds keys and decrypts when the user is present. Compromise of the always-on component then
  yields envelopes nobody can read.
- Throttle hard on battery, full activity on AC.

**Wake beacon role (added after Don's question about whether a home server fixes push).** It does not
— sending an APNs push requires provider credentials tied to our developer account, and shipping that
private key to every user's machine would let anyone push to every user of the app. Not a tradeoff, a
non-starter.

But a persistent node advertising continuously *is* a wake trigger: the platform can relaunch a
suspended mobile app when a peripheral with a known service UUID appears, so arriving home wakes the
app and flushes the queue — no server, no push, no internet. Bluetooth range, so a room or two.

That makes the desktop app three things rather than one: mailbox, LAN sync point, and wake beacon.
It still does nothing when the user is away from it, which leaves `SPEC-ORIGINAL` §27's promise
intact — opportunistic delivery, not a phone that buzzes everywhere.

**Note.** Internet-reachable personal nodes are a power-user feature — consumer NAT means most users
cannot expose one without port forwarding. On the LAN it works for everyone.

### A4. Message retention splits in two. — *Settled*
SPEC-ORIGINAL §18's single "~24 h maximum relay lifetime" conflated two different things.

- **Relay retention** (carrying a stranger's traffic): short-bounded, ~24 h, abuse control on storage
  you do not own.
- **Sender retention** (your own outbound message): indefinite, until delivered or cancelled. It is
  the user's message on the user's storage.

**Why.** Asked where a message waits when both apps are not open, the answer is *the sender's own
device* — that is what the Queued state is. As originally written, a message would have expired off
its own sender's phone in a day for no reason.

### A5. Connectivity tiers — Don's model, now normative. — *Settled by Don*
The spec treated no-internet as the normal case. For most users, most of the time, it is not.

| Tier | Situation | Behaviour |
|---|---|---|
| **1** | No internet at all — no cellular, no Wi-Fi | Mesh only: proximity and courier. The aircraft case |
| **2** | Internet present but restricted — client isolation, corporate or captive networks | Local transports plus whatever internet is actually permitted; never assume same-SSID means reachable (`SPEC-ORIGINAL` §7) |
| **3** | Full internet | All routes. **Local preferred, internet last** — not because it is worse functionally, but because it is worse for privacy |

Tier 3 is the common case and the spec was wrong to treat it as exceptional. This needs no new
machinery: route cost already penalises the gateway hop at 120, so "prefer local, fall back to
internet" falls out of scoring. The UI must show which tier is live, per `SPEC-ORIGINAL` §29 — the
individual transport states, never a generic "offline".

### A6. A mailbox and notification service, operated by Don. — *Settled by Don*
**Reverses part of A1.** Recorded with the reasoning, because the reversal is deliberate.

**Why.** A1's no-server position served proximity and courier delivery, and left everyone else with
"install a desktop app" — which most people will not do. Don: *"not everyone will install a desktop
app - most will not bother certainly."* A privacy tool that cannot deliver a message is not a privacy
win, and the user-operated node (`A3`) is a minority answer to a majority problem.

**What it holds, precisely.** Encrypted envelopes only, plus a rotating destination token, a bucketed
size, an upload time, a download time, and two IP addresses. **It cannot decrypt anything, ever** —
a content request returns ciphertext, and that is checkable rather than promised. Tokens rotate every
ten minutes and there are no accounts, so nothing durable attaches to a pattern.

**Mandatory mitigations — these are the design, not nice-to-haves:**
- **IP logging disabled explicitly.** Nginx, Apache and most hosts log by default. This single config
  decision carries more privacy weight than any protocol choice in this document.
- **Held only as long as delivery takes.** Don: *"we hold them only as long as it takes to deliver
  them of course."* An envelope is deleted on **confirmed receipt**, not on fetch — the recipient
  issues an explicit delete after a completed transfer, because dropping it the moment it is read
  loses the message if the transfer failed halfway and neither party would know. The 24-hour ceiling
  is only a backstop for envelopes nobody ever collects.

  The consequence is worth stating plainly: **a request served tomorrow finds nothing for anything
  already delivered.** At any moment the server holds only what is genuinely in flight — typically
  seconds or minutes, not a day.
- **Blind deposit and retrieval** — PUT to a token, GET by token, no identity on either side.
- **No accounts, ever** (`F1`).
- **Opt-in, off by default, and per-contact** rather than global.
- **Tried last**, after local routes fail, so proximity stays the norm.
- **Recipient veto** via the stricter-wins rule (`D6`): Alice choosing the server exposes Bob, who did
  not choose. Same asymmetry as the gateway hop (`D7`).

**Residual risks, stated plainly:**
- Live real-time access to the server could correlate uploads against downloads. That is a
  wiretap-shaped capability, not a records request — a meaningful distinction.
- **Operating it makes Don a service provider**, which is a legal-position question separate from what
  the data reveals (`EXPORT-COMPLIANCE.md`, and the frameworks noted in `A1`). Needs counsel before
  shipping into the UK or EU.

**B1 amended in consequence.** Targeted push was a permanent non-goal *because there was no server*.
With a mailbox, the server already knows a message exists for a token, so push metadata is **strictly
less** than what the mailbox reveals. Refusing push now would pay the cost and decline the benefit.
Targeted push ships with the mailbox, under the same opt-in and the same recipient veto.

### A8. Inviting and pairing are client actions. The server stays out of both. — *Settled by Don*
Don: *"if a user wants to invite someone to the app that's a client action... the server doesn't have
to have anything to do with either route and should stay out of it."* The concrete form of `A7`.

**Two stages, and only one carries secrets:**

1. **The invitation** — "come install this app." A bare app-store URL. No keys, no tokens, no
   identifiers. Because it carries nothing, it can travel any channel: SMS, email, a written note.
2. **The pairing** — key material, carried by the user over a channel *they* chose, with the short
   authentication string compared out of band.

**The SAS must be derived from the exchanged keys, not a random PIN.** This is the difference between
a defence and a ritual. If two people exchange keys by email and then read each other a random
pairing code, an attacker who substituted a key during that exchange still passes — the code attests
to nothing about the keys. A number computed *from both public keys* changes under substitution and
the comparison fails. `SPEC-ORIGINAL` §10's `742 983 125` is the right primitive; it must be bound to
the keys.

**Voice authenticates only if the voice is recognised.** For a spouse or colleague, a voice-compared
SAS is about as strong as in-person QR — the comparison does the work, proximity never did. For
someone never spoken to before, it proves nothing (`E1` tier 3).

**Two traps, both tempting now that infrastructure exists:**

- **The invite link must be a bare store URL.** A campaign parameter, or a domain we control that
  redirects, would tell us someone invited someone, with timing and two IP addresses. That puts us
  back in the path through a marketing convenience rather than a decision.
- **Deferred deep linking is forbidden.** The polished flow — tap invite, install, app opens already
  knowing who invited you — requires a server to hold that association across the install. It is what
  every consumer app does, it will be proposed as harmless, and it is exactly the capability `A7`
  exists to deny us.

---

### A9. Satellite direct-to-cell is closed to us. — *Noted, researched 2026-09-01*
Don has T-Mobile T-Satellite (Starlink Direct to Cell) on his Android. The obvious idea — one
satellite-capable phone acting as the mesh's uplink for a group with no coverage — is architecturally
sound and **practically unavailable.**

**Why.** Android blocks unoptimized application traffic over an NTN link at the network-policy layer,
using firewall chains and data-saver extensions, and **the allow-list is controlled by carriers and
device manufacturers.** Apps cannot bypass it. Getting on that list is a commercial arrangement with
T-Mobile and SpaceX, not an engineering task. *(Verified against AOSP's own satellite documentation.
A claim that an app can self-declare via a `PROPERTY_SATELLITE_DATA_OPTIMIZED` manifest property did
not verify there — treat it as unconfirmed.)*

**What is live today** (September 2026): SMS, MMS, RCS, location sharing and text-to-911, plus data
for a curated set of partnered apps. Roughly 2–4 Mbps, needs clear sky. Native voice and general
data are roadmap, tied to later satellites.

**The one to watch is AST SpaceMobile.** It uses partner spectrum and presents as ordinary LTE to an
unmodified phone, so third-party traffic should route **transparently with no allow-list** — which is
exactly what T-Satellite denies us. Not commercially live; targeted early 2027. If that ships as
described, the gateway idea below becomes available with no new protocol work.

**Apple's satellite** (Globalstar, iPhone 14+) is Emergency SOS, Messages and Find My only. No
general data, no third-party access.

**Nothing to build now.** But when a transparent direct-to-cell service does arrive, it needs no new
mechanism: it is `D7`'s gateway hop with a satellite backhaul, and the route cost already prices a
gateway at 120 against BLE's 40, so local paths keep winning and satellite is used only when nothing
else reaches. The fit is unusually good — tiny bandwidth is fine for a 256-byte padded message, high
latency is fine for a protocol built around messages that wait, and a mesh aggregating many people
onto one scarce link is the best possible use of it.

**And the same caution applies as any gateway:** satellite routes through a carrier, which is an
intermediary with billing records. Opt-in, off by default, per contact, tried last, recipient can
refuse (`D6`, `D7`).

### A7. The server MUST NEVER be in the key-distribution path. — *Settled*
A hard line, recorded while it is still free.

An end-to-end operator cannot be compelled to decrypt. They **can** be compelled to hand someone the
wrong key, if they are ever the party distributing keys. Key custody is not the risk; **key
distribution is.** It is the one attack that turns "we cannot decrypt" from a structural fact back
into a promise.

Today this holds by construction: pairing is QR in person, or a blob the user carries over a channel
we do not operate (`E4`).

**The danger arrives with the server.** Once infrastructure exists, the obvious next feature is
letting it help people pair — hosting invites, looking up contacts, smoothing onboarding. That is the
moment we become a party who could substitute a key.

**Therefore, normative:** no public key, pairing blob, invite, or any key material may pass through
our infrastructure, in any release, for any convenience. The mailbox carries encrypted envelopes and
nothing else. A feature request that violates this is refused regardless of how much friction it
would remove.

---

## B. Notifications

### B1. Notification ladder — local wake first, broadcast push at most, targeted push never. — *Settled*
1. **Local notification** on decrypt. No server, no exposure, works locked. Free.
2. **Background BLE wake** (CoreBluetooth background central + state restoration). No server. Preferred
   mechanism for proximity wake. *Believed, not measured — on the verify list.*
3. **Broadcast push**, only if 1–2 prove insufficient. Same empty payload to every enrolled device on
   a schedule, with no targeting and no per-user request. The server is a cron job, not a router.
4. **Targeted push: never.** Documented as a non-goal, not a deferred feature.

**Why targeted push is excluded permanently.** "Wake Bob for Alice" hands the operator a social graph
with timing — precisely the asset subpoenas ask for, and worse than content, which is already
unreadable. Nobody should add it in year two as an obvious UX win.

### B2. If broadcast push ships, three things must hold. — *Settled*
- **Device tokens are not anonymous to Apple or Google.** They are opaque to *us* only. A compelled
  list establishes "this device has ChannelMessenger installed."

  > **Corrected 2026-09-01, Don's objection sustained.** An earlier version of this file treated that
  > install-base exposure as a serious harm. It is not. **Apple already holds a definitive purchase
  > list** for every buyer, with far better identity resolution than a device token, and it is
  > subpoenable from Apple whether or not we hold anything. A push-token roster would be a strictly
  > weaker, redundant copy, and its marginal harm is small.
  >
  > **What survives is a different objection.** Running any server makes us an **operator**, and
  > operator status is what regulatory duties attach to (`A1`). The reason to avoid a broadcast push
  > server is jurisdictional position, not data sensitivity.
  >
  > **Targeted push remains a permanent non-goal** on the original grounds, which are unaffected: a
  > correspondence graph with timing is an asset Apple genuinely does *not* have, so building it
  > creates something new rather than duplicating something that already exists.

  The disclosure should still say plainly what the list is; "we may collect device identifiers" would
  be technically true and functionally evasive.
- **Local and remote notifications are separately controllable.** iOS shows one permission prompt, but
  "display notifications" and "register my device with the developer's server" are different things.
  Bundling them means "do you want notifications?" silently means "may I put you on a list."
- **Off means deleted**, server-side, plus pruning of tokens not refreshed within a window — a live
  roster, not an archive. And **access logging must be explicitly disabled**: nginx, Apache and most
  hosts log IPs by default, which would rebuild the subpoenable record by accident, in a config file.

---

## C. Cryptography and wire format

### C1. AAD is a fixed-layout byte concatenation, not a CBOR re-encoding. — *Settled*
Authentication must not depend on canonical CBOR.

**Why.** If AAD is "the CBOR encoding of the header," two implementations that order map keys
differently compute different tags and *every* message fails authentication — surfacing as
"authentication failed," which points a developer at the crypto, where nothing is wrong. With a
fixed-width, fixed-order AAD, a CBOR disagreement produces a visible parse error instead. This
removes an entire class of silent cross-platform divergence from the security-critical path.

### C2. Canonical CBOR is pinned to RFC 8949 §4.2.1. — *Settled*
Bytewise lexicographic ordering of encoded keys. The older RFC 7049 length-first ordering is
**forbidden by name**. Integer map keys, not strings. Shortest-form integers. No indefinite-length
items.

**Why.** "Canonical CBOR" is ambiguous — two competing standards disagree on key order and libraries
default differently. Picking one without naming it is how you get the exact bug you were avoiding.

### C3. `destinationToken` and `presenceToken` are separately derived. — *Settled*
Same pairwise secret and epoch, different HKDF labels. `ROUTE_QUERY` carries the presence token, so
discovery works off relays' beacon caches. The `ENVELOPE` carries the destination token and is
forwarded along reverse-path state by query ID, never by token matching.

**Why.** If they were one value, a relay that heard a beacon could link it to a message it is
carrying. Courier mode still works because it runs on inventory exchange, not token recognition. Cost
is one extra HKDF call.

### C4. Mutable fields are excluded from AAD; immutable ones are included. — *Settled*
- `hopLimit` — **excluded**. It decrements at every relay; including it would break the tag on the
  first hop.
- `transitPolicy` — **included**. Immutable end to end, so tampering becomes detectable by the
  recipient. Zero extra bytes.

**Consequence.** Hop count is unauthenticated and adversarially mutable — a relay can decrement it
faster (denial) or raise it (amplification). Pairwise keys cannot prevent this because relays share
no secret with the sender. Defence is local policy only: each node's own ceiling, storage caps, and
per-encounter rate limits. This must be stated plainly in THREAT-WALKTHROUGH.

### C5. Pairing generation counter. — *Settled*
`routingSecret = HKDF(sharedSecret, "routing-v1" ‖ generation)`. Re-pairing bumps the generation and
every previously derived token dies instantly; conversation history survives because it is keyed
separately. Deleting a contact destroys the pairwise secret, making anything still in flight
permanently undecryptable — including by us.

### C6. Bucketed padding is mandatory; compression is optional inside it. — *Settled*
Pad to coarse buckets (256 B / 1 KB / 4 KB / 16 KB / 32 KB). Compress within the padding if desired.

**Why.** `payloadLength` is visible to every relay, and compress-then-encrypt makes ciphertext length
reflect content *entropy* — the CRIME/BREACH family. Chosen-plaintext injection is hard here, but the
weaker leak is real: stock phrases compress to recognisable sizes. Padding fixes both the raw-length
side channel and the entropy leak. Note that padding and compression partly spend each other;
padding is the one to prioritise.

**Details:**
- The "is compressed" flag lives **inside the encrypted payload**, never in the header.
- Hard decompressed-size cap enforced *during* streaming decompression, failing closed — the
  compression equivalent of SPEC-ORIGINAL §39's "strict length bounds before allocation."
- Compression needs no canonical determinism (unlike CBOR): it is inside the encryption boundary, so
  only the *format* must be pinned. One fewer divergence trap.
- A preset dictionary is what rescues compression at 100-byte lengths, where generic algorithms often
  *expand* short text. It is a shipped constant, so version it: `CompressionDictionary v1`.
- **Forward rule:** never compress authored and received content in the same context — this is what
  would reintroduce CRIME properly the day someone adds quoted replies or forwarding.

### C7. Text only; size enforced on the reassembled payload. — *Settled*
~32 KB maximum. Fragmentation exists (SPEC-ORIGINAL §13), and fragmentation is what could quietly
undo the cap — so the limit is checked against a declared total size *before* reassembly begins, with
a hard cap on fragment count.

**Why text-only is right, beyond storage.** The binding constraint is **encounter duration**. Two
people passing in an aisle may have 10–30 seconds of contact. A 32 KB envelope is a few seconds at
realistic BLE throughput; a 3 MB photo would fail to complete before the encounter ends, repeatedly,
burning battery on both sides with nothing delivered.

**Honest caveat.** Text-only is enforced by the sending client, not the protocol — relays cannot
inspect encrypted payloads, and 32 KB of base64 is ~24 KB of binary. It stops casual and mainstream
misuse; it is not a hard barrier against a modified client.

---

## D. Routing and policy

### D1. All tunable numbers live in a policy table, as SHOULD, not MUST. — *Settled*
Hop limits, retention, storage caps, epoch length, ring-search timers, padding buckets.

**Why.** Don: *"this is a number that may change with time and may change with particular topology in
the moment."* Putting the value in the wire format and the *policy* in the node means the number can
change with no version bump and no flag day; mixed-version nodes interoperate.

**Also:** every relay enforces its own ceiling and clamps or rejects anything above it — otherwise one
permissive build injects `hopLimit = 200` and conscripts everyone else into carrying it.

**Worth recording:** with proper duplicate suppression the flood is bounded by node count, not by
branching-factor^hops — the seen-table does the load-bearing work, and hop limit is mostly a *scope*
knob. Where it genuinely binds is store-and-forward, and there the storage cap bites first: at 32 KB
per envelope, a 25–100 MB budget is ~760–3,050 envelopes.

### D2. Expanding ring search. — *Settled*
Try `hopLimit = 1`, short timeout, retry at 2, then 3. Nearby destinations resolve in one hop and
never touch the wider cluster. A sender may also pick its initial ring from observed local density.
Straight from the AODV literature Don asked us to borrow from.

### D3. The sender never learns the path. — *Settled*
`ROUTE_RESPONSE` carries next hop, coarse hop count, and route cost. Never the path. This holds in
every build, so it is a property of the protocol rather than of a setting.

**Development visibility** comes from elsewhere: in simulation the harness already sees everything; on
real devices each node logs only `queryID · received from peer X · forwarded to peer Y`, and paths are
reconstructed offline from logs pulled off devices you physically own. Behind a **compile-time
feature flag**, not a runtime toggle — a release binary should be incapable of producing the log,
rather than one flipped preference away from it.

**Residual leak, documented not fixed:** Alice still learns Carol is *n* hops away, a coarse proximity
signal over repeated queries. Alice is a paired contact and cost is needed for path selection, so this
is accepted and stated in THREAT-WALKTHROUGH.

### D4. Route discovery is AODV-derived. — *Settled*
Query ID, hop budget, duplicate suppression, short-lived reverse-path state, response along the
reverse path — this is AODV with opaque tokens substituted for addresses. PROTOCOL.md says so
explicitly and documents deliberate deviations.

**Why.** Don: *"there is quite a bit of routing literature we can borrow from rather than inventing
every mechanism ourselves."* That line did not survive into SPEC.md. Naming the ancestry buys twenty
years of known failure modes — route reply storms, local repair vs. full rediscovery, stale-route
loops.

### D5. `transitPolicy`: sender-declared, default local-only, binary. — *Settled*
A cleartext outer-header field (relays must read it to honour it), included in the AAD (C4).

- **Default restrictive.** An optional "no internet" flag that only cautious users set becomes a
  selector for exactly the traffic someone cared about. Inverting it puts the bulk of traffic in the
  restrictive class and makes *permitting* gateway transit the deliberate act.
- **Binary, not a bitfield.** Every additional bit is another feature relays can sort traffic by.
  Expressiveness costs anonymity here.
- **Advisory, not enforceable.** Relays share no key with the sender. The UI must not imply otherwise.

### D6. Stricter-wins, with a per-field direction table. — *Settled*
Don: *"when the security profile and allowed pathways differs between sender and recipient, the more
strict always rules."*

- **`min()` is not universally the strict operator.** For hop limit, retention and disappearing
  timers, stricter is smaller. For **padding bucket size, stricter is larger.** PROTOCOL.md carries an
  explicit table of field → direction → operator. No inference.
- **Not every setting participates.** Relay mode (Off / Contacts Only / Community Mesh) governs what
  *my* device does for strangers; it is not a per-conversation property. Same for push enrollment.
- **Policies piggyback inside the encrypted payload** of ordinary messages — no new packet type, no
  new traffic pattern. Convergence is therefore **lazy**: a tightened setting is not honoured until the
  peer has received one message carrying it, and is not retroactive.
- **When a peer's policy is unknown, apply the strictest value.** Fail closed, relax as information
  arrives.

### D7. Internet gateway hop is a weaker privacy tier. — *Settled*
One node with connectivity can carry a message off the local mesh toward a destination running its
own reachable node (A3).

**The cost:** the gateway relay must learn the destination's network address in order to dial it — a
stable, location-revealing identifier, exactly the class of thing SPEC-REVISION §1.5 says relays must
not accumulate. So: off by default, opt-in per contact, with an option to gateway only through
**paired** peers, and labelled in the UI as a distinct tier.

**The asymmetry worth noting:** the recipient's control is *structural* where the sender's is
advisory. No published reachability hint means no gateway hop is possible at all — enforced by the
absence of the information, not by relays behaving well. The place the rule matters most is the place
it does not depend on good behaviour.

**No envelope changes required.** Because SPEC-ORIGINAL §2 makes the Envelope byte-identical
regardless of path, a BLE → BLE → internet → BLE route needs no new packet type and no re-encryption
at the boundary. The `transportPenalty` term in the cost formula already covers it.

### D8. Route cost test vector must be in LinkQuality units. — *Settled*
SPEC-REVISION mandates a generic LinkQuality 0–255 as the normative abstraction, but states the
required vector in dBm (Route B −61/−59 beats Route A −42/−88). A conformance vector in a unit the
formula does not take is not testable. It becomes two vectors: the dBm→LinkQuality mapping contract
for BLE, and the route-cost comparison in LinkQuality units.

*(Note: SPEC-REVISION gives two different RSSI examples — −42/−89 vs −62/−58, then −42/−88 vs
−61/−59. The second pair is the mandated one. The discrepancy confirms these are illustrative
figures.)*

---

## E. Identity, pairing and contacts

### E1. Three pairing tiers, with verification as a permanent visible property. — *Settled*
1. **In person (QR/NFC)** — mutual, short auth string compared aloud, nothing leaves the two devices,
   no trail anywhere. Badged as such permanently.
2. **Remote, then verified** — blob over an outside channel, then auth-string comparison on a call
   where the parties would recognise each other. Cryptographically as strong as in person; the
   comparison does the work, not the proximity.
3. **Remote, unverified** — accepted on trust, marked **Unverified** permanently until verified.

A one-time modal that gets tapped through is worthless; a persistent badge on the contact and in the
conversation header is honest.

**The honest limit:** if two people have genuinely never met and never will, there is nobody to verify
against. They are trusting the channel the blob arrived over, completely. That is a property of the
situation, not a flaw in the design — the difference is whether the app says so.

### E2. Remote pairing does not enable remote messaging. — *Settled*
With no server and local-only transport, pairing establishes trust; delivery still requires radio
proximity or a courier chain. Pairing by email with someone you never meet yields a perfect contact
entry and a permanently Queued conversation.

The real use case is **"pair now, talk when we're near later"** — family before a flight, a
conference, a neighbourhood. The UI must not promise what the transport cannot do.

### E3. Invite blob hardening. — *Settled*
Unlike a QR held up for three seconds, a blob sits in a mailbox and a backup forever.

- **Single-use** — the pairing nonce is invalidated by first successful completion; a forwarded copy
  fails.
- **Expiring** — hours or days.
- **No alias by default** — SPEC-ORIGINAL §10's optional human-readable name is fine in person, but in
  an email it is the field that makes the blob identifiable to anyone who finds it. Opt-in per invite.
- Carries public keys and a nonce only. No tokens, no routing state, no location.
- **Two round trips are unavoidable** (X25519 needs both public keys): invite → response → paired.

### E4. The transport channel is the user's, never ours. — *Settled*
The app exports and imports pairing blobs and is deliberately agnostic about how they travelled.
Providing the channel would make us the intermediary A1 rules out. The trail is real and it lives on
someone else's infrastructure.

### E5. No-forward / no-screenshot is an *intent marker*, never a control. — *Settled*
Cooperative clients hide forward and copy affordances and apply `FLAG_SECURE` on Android. That is the
entire promise. Worded as intent in the UI, it is honest; worded as enforcement, it is not.
**No back-channel notification of screenshots or shares.**

**Why.** SPEC-ORIGINAL §32 already declines to claim protection against a compromised destination
device, and a modified client is exactly that. Android can genuinely block capture via `FLAG_SECURE`;
iOS can only detect after the fact; macOS and Windows can do neither; and a second phone pointed at
the screen defeats all four, permanently.

The specific harm of the back-channel is **false confidence** — Alice would reasonably read the
*absence* of a notification as the absence of capture, which is wrong against exactly the people she
should worry about.

`FLAG_SECURE` and iOS capture detection still ship as **default hygiene** (keeping content out of the
app-switcher thumbnail and casual over-the-shoulder capture) — a real everyday benefit nobody has to
be misled about.

### E6. No link previews. — *Settled*
Rendering a preview requires fetching the URL, which leaks the recipient's network location to
whoever controls that link and pulls remote content onto the device without the user choosing to open
it. Show the URL as text. Privacy win, safety win, less code. Recorded as a design rule so it is not
"fixed" later by someone who cannot see what it costs.

---

## F. Product and commercial

### F1. Paid app. Licensing identity never touches mesh identity. — *Settled*
No account ID, receipt, subscriber token or purchase state in the envelope, the pairing QR, the
routing tokens, or any capability bit. **No licensing state on the wire, ever** — if free and paid
nodes coexist, a "paying user" bit is both a fingerprint and a discrimination vector.

Build the wall now and adding accounts later costs nothing; skip it and retrofitting accounts
contaminates the identity model SPEC-ORIGINAL §9 carefully isolated from the device.

**Worth knowing:** accounts and monetisation are less coupled than they appear. Both one-time purchase
and subscription work through StoreKit and Play Billing with **no ChannelMessenger account at all**. The
trust cost is the price of *cross-platform portability*, not of monetising — and Windows is where it
bites, since it shares neither store. An account-free alternative that fits this product: a signed
offline license token, verified locally, no server, no identity.

**Unresolved tension for Don:** a subscription is a recurring online entitlement check bolted to an
app whose premise is working without connectivity. Solvable with a long grace period, but the
pressure points toward one-time purchase.

### F2. There is no moderation capability, permanently, by construction. — *Settled*
No content scanning (E2E, no server), no operator to receive a report and act, no bans, no accounts to
suspend. Local remedies — block, delete contact, destroy the pairwise secret — are the entire
enforcement surface and they belong to the user.

**The structural safety story**, which is stronger than the size limit and worth articulating clearly
because it will be asked: no groups, channels, directory, search, or forwarding-to-many; every
contact requires an individual out-of-band pairing exchange; no server hosts anything; transport is
local-only. Distribution networks depend on reach, and there is none.

### F3. What to promote is the structural claim, not cosmetic controls. — *Settled*
No server copy of any message, no operator who can be compelled, no metadata anywhere but the two
devices — not as policy but because the infrastructure does not exist. Almost nobody can say that.
Spending credibility on a control a teenager defeats with a second phone would undercut the part of
the story that is true.

### F4. TestFlight goes through Xcode Cloud, which forces a remote repo. — *Settled*
Don's local Xcode is a 27 beta that ADC will not accept, so builds go through Xcode Cloud — which
selects its own Xcode version, removing the beta as a constraint.

**Consequence:** Xcode Cloud builds from a *remote* source repository and cannot build from a
local-only checkout. SPEC.md §6 decision 1 is therefore resolved by constraint: this repo must live
on a private remote.

**Cost to bank against the Rust decision:** a Rust core in Xcode Cloud needs a `ci_post_clone.sh` to
install the toolchain and build the xcframework before `xcodebuild` runs — real CI work that
Swift-first would not need.

### F5. App Review cannot test a mesh app on one device. — *Settled*
Review evaluates on a single handset; the product requires two devices in proximity. Apps like this
get rejected as non-functional. A demo/loopback mode plus explicit reviewer notes is far cheaper to
design in now than to add under a rejection — so the state machine accommodates it from the start.

Also expect the **export-compliance declaration** for end-to-end encryption, and scrutiny of the
`bluetooth-central` / `bluetooth-peripheral` background modes. Export controls are now researched
against primary sources in `EXPORT-COMPLIANCE.md` (measured 2026-09-01) — the headline is that
publicly available 5D002 source code is *not subject to the EAR* at all, which makes open-sourcing a
compliance simplification as well as a verifiability one.

### F6. Rust shared core, confirmed — with cost-deferring sequencing. — *Settled*
Don delegated this: *"choose whatever is the best most durable and safest path to product and easiest
to maintain."* Resolves SPEC.md §6.3.

**Decision: `mesh-core-rs`,** owning crypto, envelopes, identities, route discovery, routing,
duplicate suppression, serialization, receipts and the protocol state machine. UniFFI bindings for
Swift and Kotlin; C ABI or generated bindings for Windows. Platform apps own only radio APIs, UI,
lifecycle, notifications, permissions and secure key storage.

**Why.** The real question is *one implementation or four*. Swift-first means writing the crypto and
routing state machine again in Kotlin, and again for Windows — every bug fixed three or four times,
every fix an opportunity for exactly the divergence SPEC-REVISION §1.2 exists to prevent. The
toolchain cost is paid once; the divergence cost is paid every week for the life of the product.
Memory safety also matters most in A3's persistent node, which runs 24/7 parsing attacker-controlled
input. Swift on Android and Windows is a far less mature library target than Rust.

**Sequencing, which defers nearly all of the cost:**
1. `PROTOCOL.md` + `vectors/` — language-neutral.
2. `mesh-core-rs` with MockTransport, **pure Rust, no FFI.** The whole protocol and the
   Alice/Bob/Carol simulation run under `cargo test` — no simulator, no devices, no bindings. The
   hard logic gets built at maximum iteration speed.
3. UniFFI + xcframework for iOS only; one real app working.
4. cargo-ndk for Android.

Steps 1–2 are the bulk of the difficult work and pay no binding cost at all.

**Libraries.** Standard primitives only (SPEC-ORIGINAL §11): X25519 and Ed25519 via the dalek crates,
ChaCha20-Poly1305 and HKDF from RustCrypto, and a CBOR crate chosen specifically for deterministic
encoding control per C2. Exact crates pinned at step 2, not asserted from memory.

### F7. Closed source for v1 — reversible, and deliberately so. — *Settled*
Don, after weighing a future sale: *"proceed closed for now then."*

**Why the default flipped.** An earlier recommendation here leaned open-core for the export relief
and the reproducible-builds property. Exit intent changes it, because **publishing is a one-way
door**: every released version stays under its licence forever, and an acquirer whose thesis needs
proprietary control gets a fork on day one. Closed → open is available at any moment; open → closed
never is.

**And waiting is nearly free.** Staying closed costs one CSV filing a year (Route B in
`EXPORT-COMPLIANCE.md` §2) and nothing else. One annual filing buys complete optionality on an
irreversible decision.

**What is deferred, not lost:** EAR relief under 742.15(b)(1), and the reproducible-builds answer to
the app-store update channel — which only starts mattering at a user count where a targeted build is
worth someone's trouble.

**Obligations this creates, starting at first export (App Store release):**
- Annual self-classification report to the ENC Encryption Request Coordinator, **due 1 February**,
  covering the prior calendar year, twelve-field CSV. Put it in a calendar, not in someone's head —
  the second year is where this gets forgotten.
- `ITSAppUsesNonExemptEncryption` = YES; French declaration if shipping to France; no CCATS on the
  industry-standard-algorithm reading.

**Rules that keep the door open:**
- **No outside contributions without a contributor agreement**, from the first one. Unowned
  contributed copyright cannot be cleanly conveyed in a sale; it surfaces in every diligence, delays
  closing, and reduces price. This applies the moment the repo is ever made public.
- Do not adopt a source-available licence assuming it earns EAR relief — "publicly available" turns
  on unrestricted dissemination, and a restrictive licence may take the downsides of publishing with
  none of the relief. **Unverified**; confirm before choosing any licence.

### F8. Consequences of the privacy architecture for a future sale. — *Noted, not a decision*
Recorded because it follows from decisions already made and is better known now than in a data room.

An acquirer of a consumer messenger is mostly buying **users** — retention cohorts, actives, growth.
This product has no accounts, no server, no telemetry and no analytics, so the honest answer to "how
many active users" is App Store unit sales and download counts, and nothing else. No engagement data
exists to produce, by construction.

That is not a reason to change the architecture; it is the product. But it means the standard
consumer-app valuation story is unavailable, and the buyer has to be one who values the
architecture rather than one running the usual playbook.

**Transferable assets are therefore narrow:** trademark and brand, the proprietary apps, the App
Store listing with its reviews and ranking, revenue, and Don.

**Three things worth doing before launch, not after:**
1. **An entity owning everything** — code, trademark, developer account. Selling a company is far
   simpler than assigning assets held personally.
2. **Register the trademark early.** Given the asset list, the name may be the most transferable
   thing here.
3. **Check whether the Apple Developer account is individual or organization.** App transfers between
   accounts have real constraints and an individual account is the awkward case. Cheap to fix now,
   while setting up TestFlight; expensive once an app is live. *Verify current transfer rules against
   Apple's documentation.*

### F9. Windows is in v1 scope. — *Settled by Don*
Don: *"agree build for windows planning too."* Resolves SPEC.md §6.2 — not design-for-and-defer, but
in scope and planned for.

**Consequences:**
- Rust core compiles for Windows via C ABI or generated bindings (`SPEC-REVISION` §1.2). Add to CI
  targets alongside iOS and Android.
- Windows Wi-Fi Direct is a platform-specific transport adapter. Microsoft's older Wi-Fi Direct
  **Services** layer is deprecated — do not depend on it (`SPEC-REVISION` §1.9).
- A Windows persistent node is covered by `A3` unchanged, including the wake-beacon role.
- **It appeared to narrow F1 — and F10 dissolves that.** Windows shares neither the App Store nor
  Play Billing, so it looked like the platform where entitlement stops being invisible. But if the
  desktop app is free (`F10`) there is no cross-platform entitlement to link at all: the paid product
  is iOS and Android, each carried invisibly by its own store, and no account is needed anywhere.

### F10. The desktop app is free. — *Settled by Don*
Don: *"a pc install is both a relay / launchd server / and a client all in one and is optional and
could be free or not perhaps."*

**Decision: free.** It does two useful things at once.

**It removes the licensing problem `F9` created.** With no paid desktop there is no cross-platform
entitlement to link, so the paid product is iOS and Android — each carried invisibly by its own store,
with no account anywhere and no offline licence token needed. Windows being in scope stops constraining
the pricing question entirely.

**It seeds density when density is worst.** The mesh has no value at zero users, and paid-only at
launch guarantees minimum density exactly when it matters most. A free always-on relay anyone can
install is the cleanest answer: **infrastructure is free, the phone is the product.** A desktop node
is relay, persistent mailbox, wake beacon (`PROTOCOL.md` §7.8) and full client in one.

---

## G. Defaulted — reversible on request

### G1. "Contacts Only" means *relay for peers I have paired with*. — **Settled by Don**
Don, 2026-09-01: *"contacts only are contacts within the app and have nothing to do with iphone apps
unless the user uses that to do a less secure invite to someone not near enough for in person
pairing."*

**Terminology, now normative:** a *contact* is established by pairing inside this app. It has no
relationship to the iOS or Android address book, ever.

**Relay semantics:** Contacts Only exchanges packets with peers the user has paired with, and does not
courier for strangers. The alternative reading — relay only envelopes whose destination token I
recognise — would mean relaying only for people already being talked to, which makes the mode nearly
useless.

**Implementation consequence for the remote invite.** The user may want to send an invite via a
messaging app or email to someone too far away to pair in person (`E1` tier 2/3). Use the **system
share sheet**: the user picks the recipient in the OS's own UI and the app receives nothing back but
a completion. The app produces the invite blob and hands it off; it never reads the address book.
This keeps `A2`'s "never request contacts permission" literally true and user-verifiable, while
supporting exactly the flow Don described.

### G2. Groups: pairwise fan-out, capped at 6, deferred out of v1. — *Settled*
One envelope per recipient. **Never a shared group key.** Fields reserved now; implementation after
the mesh is proven on hardware.

**Why never a group key.** 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 learns
those people are in a group together. That is precisely the correlation the direction byte prevents
for pairs (`C3`, `PROTOCOL.md` §4.4), reappearing at group scale. With fan-out a relay sees N
unrelated messages with N unrelated addresses. Shared keys also carry the member-removal rekey
problem, which there is no server to coordinate.

**Why deferred, revised 2026-09-01.** An earlier version of this entry put groups in v1. Don asked how
important they actually are, and weighed against **schedule risk** rather than complexity the answer
changes.

The open uncertainty in this project is not whether people want group chat. It is whether opportunistic
mesh delivery *works on real hardware* — background BLE across iOS and Android (`PROTOCOL.md` §7.9),
encounter windows in the wild, whether a courier chain ever completes outside a controlled test.
Anything that delays learning that is expensive, because a bad answer there makes group work wasted
effort on a product that needs rethinking.

Groups are also less load-bearing in the flagship scenario than they look: a family on a plane is
largely co-located and can talk. The mesh earns its keep when they are *separated*, and those messages
are usually one-to-one.

The payload fields (`PROTOCOL.md` §5.2) cost nothing sitting reserved, so groups become an
application-layer feature to switch on in v1.1.

**Cap of 6 — Don's number, adopted on resource grounds.**

> Don's stated reason was that six fits most families but not large groups that might misuse it.
> **The resource argument is the one to record, and the only one to state publicly.** Fan-out is N×
> bandwidth and N× courier storage, and six rather than sixteen nearly halves the worst case exactly
> where it hurts.
>
> The abuse rationale should not appear in the spec or in any marketing, because it does not survive
> being questioned: six is ample for organised misuse, and anyone determined would use pairwise
> threads, several groups, or another app. Stating it as a safety control invites the obvious reply
> and would be the softest claim in the product. The resource justification is true, sufficient, and
> unattackable.

**Accepted limitation:** with no server, membership is eventually consistent and two members can
briefly disagree about who is in a group. A membership digest lets clients surface divergence rather
than silently dropping someone from a conversation they believe they are in.

### G3. Two identities in v1 — phone and desktop are separate. — **Settled by Don**
Don: *"two identities better."* Linked devices deferred.

**It costs less than it appears.** The apparent problem — your Mac cannot recognise messages addressed
to you, since it holds a different identity — does not arise, because **the Mac never needs to**. It
is a well-placed relay: it stores envelopes it can neither read nor attribute, and the phone picks out
its own packets during inventory exchange when it comes home. `A3`'s keyless-mailbox property survives
exactly as specified, and pairing your phone to your own Mac is an ordinary pairing.

**Honest limit:** a home node is a mailbox for whatever physically reaches it — by mesh, courier or
gateway — not a universal inbox anyone can post to.

### G4. Encrypted identity export for device loss. — *Defaulted*
New device means new identity, and with no server every pairing is lost permanently — every contact
must re-pair in person. A passphrase-protected identity export the user stores themselves (file or
printed recovery code) is the only recovery story compatible with A1. SPEC-ORIGINAL §31 has "Reset
identity" but nothing about preserving one. Needs to exist before users have contacts worth losing.

### G5. Delivery receipts on, read receipts off by default and optional. — *Defaulted*
A read receipt reveals when you looked.

---

## H. Still open — Don

1. **One-time purchase or subscription** (F1), and whether a free relay-only tier seeds density at
   launch — the mesh has no value at zero density, and paid-only guarantees minimum density exactly
   when it matters most.
*(The private remote repo is done — `delton57/ChannelMessenger`, created 2026-09-01 per F4. Rename with
`gh repo rename` whenever a real name is chosen; the remote keeps working.)*

---

## I. Standing verification rule

Every platform API claim in this repo is marked **believed, not measured** until checked against live
vendor documentation: Apple Wi-Fi Aware, Core NFC, CoreBluetooth background modes and state
restoration, iOS Local Push Connectivity entitlement availability, `SMAppService`, Android Wi-Fi Aware / Wi-Fi Direct / `FLAG_SECURE`, Windows Wi-Fi Direct
(Microsoft's Wi-Fi Direct *Services* layer is deprecated — do not depend on it), Xcode Cloud
behaviour, and StoreKit entitlement caching.

No model's training knowledge is current on these, including this one's.

**Known specific concern:** a backgrounded iOS app moves its service UUID into an Apple-specific
overflow area readable only by another iOS device scanning for that exact UUID. Background iOS↔iOS
discovery is plausible; **background iOS↔Android may simply not work.** SPEC-REVISION §5's four-device
acceptance test assumes it does, and two of those four devices are Android.


---

## J. Product focus — corrected by Don, 1 September 2026

### J1. Direct phone-to-phone is the product. Multi-hop mesh is unproven. — *Settled by Don*
Don: *"i think it's a long shot that any relay/mesh is going to be of much practical value unless the
product really gets huge adoption… main focus of the product should be to be capable of phone to phone
direct comm for texting via bt or wifi when feasible within the range that is what it is. we need more
testing of the mesh before calling that a real feature."*

**Decision: direct BT/Wi-Fi phone-to-phone texting is the core feature.** Multi-hop relay stays in the
protocol and the Rust core — it costs nothing to keep and everything to retrofit — but it is
**experimental, not advertised, and not promised** until measured on real hardware at real density.

**Why he is right.** Opportunistic multi-hop only pays off above a critical node density. Below it,
relay carries the cost and delivers nothing. A paid privacy app in a niche will not reach that density
on day one, and building the pitch on a feature that needs a large installed base to work is building
the pitch on a bootstrap that has not happened.

**What this changes technically.** The two-backgrounded-phones case stops being an architectural
crisis, because it is not the usage pattern. **The sender is always 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." Measured working, iOS to iOS: the iPad foregrounded and
began advertising at `00:02:28` and the backgrounded iPhone logged the discovery in the same second.
*(Single correlated observation. Re-confirm before relying on it.)*

### J2. The desktop app is reframed. — *Settled by Don*
Don: *"while a desktop might be helpful in your house it's not going to be useful in the woods… i would
look at the desktop as a potential bridge to net and daemon server to help with notifications and
perhaps message in transit storage if it works out better in some way than what we could do with
netlify."*

**Decision: the desktop node is a convenience, not infrastructure.** It is an internet bridge, a
notification daemon, and possibly in-transit storage — **evaluated on the merits against the Netlify
mailbox**, not assumed better. `F10` (free) still stands; the elevation of F10 to network-critical
recorded earlier the same night is **withdrawn**.

### J3. Graceful failover is the headline feature. — *Settled by Don*
Don: *"a graceful fallover from direct bt/wife connections to what every other messenger does would be
the main selling point at this point other than our promise to not sell content and privacy and
location like most other apps do."*

**Decision: the product is a normal messenger that uses no network when it does not need one.** Direct
radio when the peer is in range; conventional internet delivery when not; the user should not have to
think about which.

**The tension this creates, and its resolution.** "What every other messenger does" means a server
holds the message until the recipient collects it — the exact thing `A7` and the whole threat model
were built to avoid. It is compatible **only** because the mailbox we already designed is blind:
sealed sender, opaque rotating tokens, no IP logs, and never in the key-distribution path. The
fallback path MUST use that blind mailbox. A conventional store-and-forward server would deliver the
same user experience and forfeit the only thing that makes the product worth buying.

### J4. Pricing — *floated by Don, not settled*
Don: *"for many users is maybe worth $9.99 to buy the app with $4.99/m service perhaps."*

**Not a decision.** Recorded so it is not re-derived. Note it is a hybrid of the two options `F1` posed
as alternatives: purchase for the app, subscription for the service. That split is defensible — the
radio features need no server and the failover path has real recurring cost — and it means the free
tier degrades to direct-radio-only rather than to nothing.


### J5. Direct delivery notifies locally. No push, no APNs token. — *Settled by Don*
Don: *"and if contact is direct via bt or wifi the receiver can create it's own notification - no push
needed."*

**Decision: a message delivered over direct radio raises a local notification.** The receiving app is
already awake — iOS woke it for the GATT write — so it posts the notification itself.

**Why this is worth more than the convenience.** A local notification needs `UNUserNotificationCenter`
authorisation but **not** `registerForRemoteNotifications()`. Skipping that second call means **no APNs
device token is ever created.** Apple never learns the app is installed on that device, and no delivery
metadata — timing, frequency, who is active — transits Apple's infrastructure. That is not a smaller
leak than push; it is the absence of one, and it is unavailable to any messenger whose delivery path
requires a server.

**It also collapses two architectures into one.** There is no notification subsystem: no token
registry, no push service, no third party, nothing to subpoena, because nothing exists.

**And it makes the strict privacy setting fully functional rather than degraded**, which sharpens the
per-user risk-tolerance model Don set out earlier:

- **Direct radio only** — local notifications, zero external infrastructure.
- **Failover enabled** — push required, because a message sitting in the blind mailbox cannot wake
  anything by itself.

Honest sentence for a store listing: *if your friend is in range, nothing about your conversation ever
leaves the two phones.*

**Caveat to state in onboarding.** This depends on the app being backgrounded, not force-quit. Apple's
TN3115 is explicit that a user swiping the app away is the one case state restoration does not recover
from — Bluetooth events will not relaunch it, so direct delivery and its notification are dead until
the user next opens the app. Every BLE messenger has this constraint and none can fix it. It should be
said in onboarding rather than discovered by a user who missed a message.

### J1 addendum — the correction to "the sender is always in the foreground"
Don: *"sender is always awake but you can type in a message when not in contact with the receiver in
which case the message stays queued to send just like it is on whatsapp… and when contact is possible
via any route the user has allowed us to use via his permission settings the message should be sent."*

**The claim in `J1` was wrong and is withdrawn.** The sender is in the foreground at *compose* time
only. Delivery happens later, from a pocket to a pocket, so two backgrounded phones is the usage
pattern, not an edge case.

**What rescues it: discovery direction need not match message direction.** Once any connection exists,
data flows both ways, so a queued message needs only *one* side able to find the other.

| Sender → Receiver | Verdict |
|---|---|
| iPhone → Android | works — iPhone scans (measured), Android advertises persistently (measured) |
| Android → iPhone | works **by inversion** — Android cannot see a sleeping iPhone (measured broken), but the iPhone finds the Android and pulls |
| Android → Android | expected fine |
| **iPhone → iPhone** | **unknown — the critical path** |

Every case resolves the same way: **the iPhone must always be the party that scans and initiates.**
Android then never has to find a sleeping iPhone, so the one thing measured broken never happens.

**The two untested links, both measurable with the existing probe:**
1. Can a backgrounded iOS scanner see a backgrounded iOS advertiser? (the overflow-area question —
   Apple claims yes for an iOS device naming that exact UUID; ours does; their documentation is thin
   and old and this is not our measurement)
2. Does a GATT write wake a backgrounded iOS peripheral? (`bluetooth-peripheral` background mode says
   yes; also unmeasured by us)

If both hold, queued phone-to-phone delivery works on iPhone with no server and no relay. If link 1
fails, two iPhones cannot find each other and iPhone-to-iPhone requires one app to be open — a real
limitation to establish before a store listing rather than after.


### J6. The mailbox buffers, expires, and never bounces. — *Don's design, one part corrected*
Don: *"perhaps netlify software needs to send the push and the message might be sitting on the net as
the sender might lose it's connection before the message leaves the sender device otherwise so the
server would be a buffer that holds and then sends and maybe has to hold forever unless the user
specifies a TTL for messages in transit with a return to sender perhaps if a message times out."*

Three of the four already exist in `server/netlify/functions`: `deposit`/`collect`/`confirm` with
delete-on-confirmation, a `MAX_AGE_MS` ceiling, and `wake()` stubbed under a hard requirement that the
payload carry no content, no sender and no conversation identifier. The fourth must not be built.

**Return-to-sender cannot work and is rejected.** The server does not know who sent an envelope — that
is sealed sender, and it is the point. Bouncing one would require the sender to identify itself at
deposit, handing the server precisely the correspondence record the design exists to withhold. That
trades the core property for a delivery receipt.

**Replacement, same user experience, no cost: the sender notices.** The sending device keeps its own
copy and polls whether its receipt is still uncollected. The server answers yes or no and learns only
that *someone* asked about that receipt, never who. The user sees "not delivered" exactly as a bounce
would show, and the retry decision stays on the device. Check marks in every other messenger are
client-side state already.

**Push defeats token rotation — state this plainly to users.** The server must be told a stable notify
id to reach a device. Destination tokens rotate; a notify id cannot, or the push stops arriving. So the
server can link every rotating token that appears alongside the same notify id. **Enabling push creates
a durable pseudonym at the server** — not a name or an address, but a thread tying a user's
conversations together. There is no engineering fix: wake-on-message requires a stable handle by
definition. It is a genuine fork, and it is exactly the risk-tolerance choice Don described — push on
and the server holds a pseudonymous identity; push off and it holds nothing durable, but messages
arrive when the app is opened or a peer is met in person.

**TTL: the argument for short is legal, not storage.** Data held is data that can be demanded — see
`LEGAL-RISK.md`. The current 24-hour ceiling is likely too aggressive for a courier product where a
recipient may not open their phone for a weekend.

**Proposed, pending Don:** user-selectable one hour to 30 days, default 7 days, **30 days as a hard
policy cap no user or operator can raise.** The cap is the protection. It is the difference between
"we do not keep it" and "we keep it until someone asks."


### J7. Oblivious mailbox — the server should not know whose envelope it holds. — *Don's design, accepted with caveats, deferred past v1*
Don: *"the server doesn't have to know who the envelope belongs to… the internet enabled device just
polls the server decrypting it's id so the server serves the right envelope and then removes all trace
of it. at storage only the receiver can tell if there is mail for him when he polls with his key."*

This is private information retrieval, and the construction is well studied.

**What it buys, stated precisely.** The current scheme leaks something not previously flagged: deposit
and collection both occur under the same rotating destination token, so **within one rotation window
the server can link "envelope deposited for T" to "T collected it."** Identity stays hidden; the
correspondence *event* does not. J7 removes that link.

**Cheap form.** Attach a short **detection tag** to each envelope rather than trial-decrypting bodies.
The recipient downloads all tags and tests them against their key. At 16 bytes per tag: 10k envelopes
in flight is a 160KB poll, 100k is 1.6MB. That is the practical ceiling and it is far beyond where this
product needs to work.

**The catch, which is the part that bites.** Downloading every tag is private. *Fetching the match* is
not — pulling envelope #4417 tells the server that one was yours, and when it was deposited. The leak
moves rather than disappears. Fix: fetch decoys alongside the real one, so the server sees a
k-anonymity set instead of a point.

**Growth path, if scale ever demands it.** Reveal a few bits of the address so the server can bucket:
a 2^-k anonymity set for a 2^k reduction in download. Privacy becomes a dial rather than a cliff. This
is fuzzy message detection (Beck, Len, Miers, Green). Worth knowing it exists; not worth building now.

**Where J7 does not help: the nudge.** A server that can wake one specific device needs a stable handle,
so the J6 pseudonym returns intact. Either push to a k-set of devices when any one has mail — waking 50
phones to deliver to one, a real battery cost — or drop push and poll on a schedule. **Scheduled polling
is the most private design available**, but iOS throttles background refresh at its own discretion, so
no interval can be promised on the platform that matters most.

**Sequencing: deferred past v1, safely.** The tag is a transport-layer field. The sealed envelope, the
AAD layout and the crypto core are untouched, so this lands later without a migration. Shipping must
not block on it.

### J8. Delivery policy is per-contact, and a policy change is itself a message. — *Don's point, with a wrinkle*
Don: *"both ends would need to be able to know this to keep an internet enabled sender trying to send a
message to a receiver that forbids it."*

**Decision: each contact record carries the peer's declared allowed pathways**, exchanged at pairing and
updatable. This is the machinery `C7`'s stricter-side-wins rule needs — that rule cannot be evaluated
without knowing the other side's policy.

**The wrinkle.** A policy change is itself a message and needs a path. If a user disables every internet
route, the notice that they did so can travel **only by direct radio**. A sender never again in radio
range will keep queueing against a route the recipient has closed.

**Therefore: a stored peer policy must carry an expiry, not be treated as current indefinitely.** On
expiry the sender falls back to the stricter assumption rather than the last one seen. Failing closed is
the correct direction: the cost is an undelivered message, and the cost of failing open is delivery over
a path the recipient deliberately shut.


### J9. Polling instead of push removes Apple and Google from the trust model. — *Don's insight, accepted*
Don: *"by polling the server you eliminate the normal notify thing… to do a server push the server has
to know in advance who it belongs to so changing to a poll eliminates that knowledge and cuts apple out
of the loop (or google)."*

**This is the strongest privacy claim the product can make and it should be treated as a headline, not a
setting.** No APNs token, no FCM registration, no third party that learns the app is installed on that
device or when it receives anything. Push does not merely leak a pseudonym to *our* server — it inserts
Apple or Google as a party who sees delivery timing for every message. Polling deletes that party.

**One correction to the mechanism.** Don proposed the server match a polled key against stored
envelopes. If the receiver polls with a value the server matches on, **the server sees that value on
every poll** — it can count that recipient's mail, time it, and link every envelope that ever matched.
Server-side matching *is* server-side knowledge, and the `J6` pseudonym returns in different clothes.

**Unless the value rotates — which is exactly the existing destination token.** So Don's design is
sound and already built; the residual leak is epoch-scoped, and the token is meaningless once rotated.
The two viable options, both of which eliminate push:

| Retrieval | Server learns | Cost |
|---|---|---|
| Rotating token, server matches | "token T got mail, T collected it" — epoch-scoped | cheap; already built |
| Client scans detection tags (`J7`) | "someone polled" | ~160KB/poll at 10k envelopes in flight |

**The hard part is timeliness on iOS, and it does not have a good answer.** `BGAppRefreshTask` runs when
iOS chooses, and iOS deprioritises apps the user rarely opens — precisely a messenger sitting quiet
waiting for mail. The mechanisms that would give reliable wakeups are silent push (defeats the purpose)
or significant-location-change (requires location permission, which is unthinkable in this product).

**State it honestly in onboarding:** on iOS, poll-only delivery means *messages arrive when you open the
app, plus opportunistically* — not "within fifteen minutes." Android is fine; `WorkManager` holds a
reliable 15-minute floor.

**Two things soften this more than they first appear.** Direct radio delivery still wakes the app
immediately — measured — so poll-only is slow on the *fallback* path, never the primary one. And
polling while the radio is already up is nearly free; the battery cost is cellular cold-start, which is
why "poll when you have internet" is the right trigger rather than a fixed clock.


### J10. The heartbeat push carries no information. — *Don's design, accepted*
Don: *"the silent push isn't to tell you you have mail, it's to arouse you so you poll to see if you
have mail and maybe you do maybe you dont - it's just a heartbeat wakeup once an hour or once every half
hour."*

**Decision: silent pushes are unconditional heartbeats on a clock, never mail notifications.**

**The gain is structural, not cosmetic.** Today `deposit.mts` accepts a `notify` id and looks it up to
wake a specific device — *that lookup is the correlation*, and it is what creates the `J6` pseudonym.
With a blind heartbeat, deposit never touches the registry. The server then holds two things that never
meet: a list of device tokens it pings on a clock, and a pile of sealed envelopes under rotating tokens.
**Nothing joins them, including for anyone who compels the entire server.**

**Apple learns nothing new.** They already know the app is installed — they sold it. What targeted push
would have given them is *delivery timing*: when a user receives messages, how often, at what hours. An
unconditional heartbeat carries none of that, because it fires identically whether mail exists or not.
The signal is decorrelated from the fact worth hiding, which is the whole trick.

**Caveat: Apple throttles `content-available`.** The budget is explicit, adaptive to user engagement,
and the system may coalesce or delay. A half-hourly heartbeat is *likely* within it. It degrades rather
than breaks — a late heartbeat is a late poll, not a lost message.

**Answering Don's question — the server is not the only possible trigger, but the alternatives are
weak:**

| Mechanism | Timeliness | Cost |
|---|---|---|
| Silent push heartbeat | good, throttled | APNs token; server sends |
| `BGAppRefreshTask` | poor for a quiet app — iOS deprioritises apps rarely opened | free, no server |
| **BLE encounter wake** | **immediate** | free; **measured working** |
| `NEAppPushProvider` | persistent on named Wi-Fi | Apple-gated entitlement |
| Significant location change | reliable | requires location — **ruled out** |
| VoIP push / audio background mode | reliable | App Review rejects it, correctly |

**BLE wake composes for free.** The app already wakes on peer encounters, so proximity to *any* node —
including the user's own laptop — can also trigger an internet poll. Someone who walks past a friend
collects from both paths at once, at no extra cost.

**`NEAppPushProvider` is the dark horse.** A persistent background extension on specified Wi-Fi networks
would give timely polling at home and at work with no push and no Apple in the loop at all. Apple gates
the entitlement case by case, historically for local messaging where APNs is unavailable — a fair
description of this app. **Worth an application; not worth planning around until granted.**

**Don's framing usefully narrows the problem.** Foreground polling is unconstrained, so an active
conversation can poll as fast as it likes. Background timeliness is needed only for *a thread starting
while the app is closed*, and 15–30 minutes for that matches what people accepted from SMS for twenty
years.


### J11. Server economics, and where a privacy tier is allowed to differ. — *Don's concern, with a line proposed*
Don: *"how well this scheme scales in terms of server resource… not all our customers are 007 / James
Bond and their privacy/utility bar is a bit different… we will allow security max settings but also will
need more relaxed modes to keep from losing money on server work costing performance and $$."*

**The heartbeat is nearly free; the polls it triggers are the entire cost.** Pushing to 10,000 devices is
one function run multiplexed to APNs. Those pushes then cause 10,000 inbound requests, each a billed
invocation. **Suppressing one heartbeat suppresses ten thousand polls.**

**Therefore Don's "no mail for anyone → wake nobody" is the highest-leverage optimisation available, and
not by a small margin.** Its privacy cost also shrinks exactly as it becomes necessary: a heartbeat then
means "someone, somewhere, has mail," which is a thin anonymity set at 100 users and noise at 10,000.
The optimisation gets *safer* as the system grows — the opposite of the usual tradeoff.

**Opt-outs come off the registry entirely.** A user who forbids internet delivery can never have mail
and must never be pushed. **Ordering matters on the yes→no transition:** deregister *first*, then drain,
or an envelope deposited between drain and deregistration is stranded. Even then it is not lost — under
`J6` the sender polls, sees it uncollected, and can deliver by radio later — but the ordering is
invisible until someone loses a message.

**BLE-triggered polls now have an economic justification as well as a latency one** (`J10`): every poll
prompted by a peer encounter is a poll the server did not have to provoke.

#### "Tier" is the wrong word — these are independent preferences

Don: *"tier is the wrong word as in my use tier is just a different set of individually chosen
preferences as to what pipes are available and how often his device will poll as it can ignore silent
pushes too."*

**Correct, and the distinction is substantive rather than cosmetic. A tier is a bundle handed out, and
bundles create classes.** Independent axes do not: if each user picks pipes, cadence and push-handling
separately, there is no named group to belong to.

**This strengthens Don's rebuttal further.** The only preference with a server-visible shadow is
cadence — and **cadence has an innocent explanation.** People set polling intervals for battery life. A
coarse "check often / check rarely" control is mundane; a setting named *maximum privacy* is a beacon.
Same mechanism, opposite signal. **Therefore these must never be presented as a privacy ladder in the
UI**, even if the underlying options are identical.

**The device may also ignore silent pushes.** iOS hands the push to the app and the app decides whether
to act. Deregistering is strictly better for a user who will never honour a heartbeat — it skips the
wakeup cost too — but *conditional* honouring (only on Wi-Fi, only during waking hours) is a real
setting, and it keeps the decision on the device where the server cannot observe it.

#### The axes, all independently chosen

| Axis | Options | Server-visible? |
|---|---|---|
| **Pipes** | BLE / Wi-Fi direct / internet mailbox, each on or off | only as presence or absence |
| **Cadence** | on-demand, slow, normal | **yes** — keep coarse |
| **Heartbeats** | honour, ignore, honour conditionally | no |
| **Push registration** | register, or never | as presence in the registry |
| **Carrying for others** | relay/courier for traffic not its own | no |
| **TTL** | how long own outbound messages may sit | as expiry metadata |

**Carrying for others deserves its own explicit consent**, not inclusion in a general privacy setting.
It costs battery and storage, and it places data on a person's device that they did not author — a
different kind of decision from how often to check mail.

#### Where a preference may and may not change confidentiality

Don is right that most users fear an ex-partner or an employer's lawyer rather than a government, and
that building only for the 007 case ships a product nobody buys. But tiering privacy has a trap, and it
is the Tor problem: **if only 2% of users run maximum privacy, membership in that 2% is itself the
signal**, and the strict tier stops protecting the people who most need it.

**Don's rebuttal, which is correct and narrows this sharply.** Don: *"the 2% guy maybe has internet
turned off as well… so only the guy with the high security really knows he's the guy with the high
security as the settings are on his phone."*

**The Tor problem requires everyone on one network behaving differently. A user with internet delivery
off is not on the network at all** — he never registers, never polls, and the server holds no record to
distinguish. Absence is not a marker when the server never knew he existed. The analogy above was wrong
for that case.

**It also makes strict mode simpler, not harder.** A user with internet off exercises no server code
path whatsoever: nothing to build, nothing to audit, nothing that can leak through a bug. And it
upgrades the claim from *"we do not log you"* to **"you never contacted us"** — categorically stronger,
and the only version still true after the server is subpoenaed.

**Where the concern does survive, much more narrowly:** among users who *do* use the server, **poll
frequency is observable.** Someone polling every two hours while everyone else polls every thirty
minutes is a visible class. Bounded — the mailbox is blind, so it is a timing pattern attached to
rotating tokens rather than to a person — but it is the one place a setting becomes a server-side
signal. **Keep cadence options coarse for this reason;** a fine-grained slider fingerprints people.

**And the governing framing is Don's:** *"more secure by nature means less deliverability and vice
versa."* This is self-enforcing rather than something the product must police. The strict user is not
getting a worse product — he is getting exactly what he asked for, and paying in latency rather than in
trust. That is a better structure than most privacy settings, which ask a user to give up something
invisible for a benefit they cannot verify.

**Consequence to keep visible in the UI:** with `C7` stricter-side-wins and `J8` per-contact policy, a
sender queueing for a radio-only contact may queue forever if they never meet again. Inherent and
correct — it is what the recipient chose — but the sending interface must make it legible, or the sender
concludes the app is broken when it is obeying the recipient.

The line below still governs preferences *among server users*:

- **Preferences MAY vary:** polling frequency, heartbeat rate, decoy traffic, whether push is accepted at
  all, TTL. These are latency, battery and cost knobs.
- **Preferences MUST NEVER vary:** sealed sender, token rotation, or whether the mailbox is blind.

Drawn there, both of Don's concerns survive. **The relaxed tier never builds a correspondence graph that
can be subpoenaed** — his founding constraint, which does not become cheaper to violate because a user
chose convenience. And **the strict tier is not a red flag**, because it is a performance setting rather
than a confidentiality one.

#### Interaction to flag: J7 does not survive frequent polling

Tag scanning at 160KB per poll × 10,000 users × 48 polls/day is roughly **2.3 TB/month of egress**.
**`J7` and heartbeat polling are in direct tension:** oblivious retrieval *or* frequent polls, not both,
short of bucketing. A further reason `J7` stays deferred rather than becoming the plan.


### J12. On-demand internet is a first-class mode, not a degenerate one. — *Don's refinement*
Don: *"even 007 needs internet sometimes unless he's always within 30 feet or so of his recipients and
senders but he can be choosy about when he exposes himself to it for the purposes of this app."*

**Correct, and it reframes the strict setting from "never" to "on my terms."** Nobody is within radio
range of their correspondents indefinitely. `J11`'s strict user is not permanently offline; he is
*deliberately* online, occasionally.

**Design consequence: when the door opens, do everything at once.** Collect all waiting mail, push all
queued outbound, drain, disconnect. **One connection, not six scattered across an hour.** The user is
choosing to be visible for a moment; the design should make that moment short and rare.

**Pad the exchange.** Fixed-size requests and responses, so an observer of his network cannot infer how
much mail he collected or whether he sent anything. Nearly free, and it removes the volume signal
outright.

**The window is bidirectional by nature** — his outbound queue is stuck until he opens it too — and that
helps: one connection that both collects and sends is indistinguishable from one that only collects.

**The honest limit, which cannot be engineered away.** Connecting to our server at all tells his network
operator that he uses this app, at that moment, from that location. The rotating token protects *whom*
he is talking to; it does not hide that he reached for us. **That is exactly the exposure he is timing**,
and treating it as something to schedule rather than eliminate is the right model. Reducing it further
means onion routing — a large lift for a benefit most users will not value. A VPN is the user's own
choice and costs the project nothing.

**One counter-intuitive caveat, recorded so it is never over-promised.** Rare, deliberate polls are more
distinguishable *as events* than routine ones: constant background traffic is its own cover, while a
single poll at 2am is isolated. It leaks no identity — the token is opaque and no IP is logged — but a
deposit and its collection can be linked with more confidence when there is little other traffic to hide
among. Not a reason to avoid the mode; a reason not to claim it is strictly better on every axis.


### J13. Pipe and cadence are separate axes. Cadence is never a promise. — *Don's correction*
Don: *"he can have internet on and polling on demand only instead of with every silent push and
depending on workload we can never guarantee exactly how often our desire to push every 30 min or 15
min is actually met."*

**`J12` conflated two axes. Separating them:**

| | Determines |
|---|---|
| **Pipe allowed** | whether senders may **deposit** for him at all |
| **Cadence** | when he **collects** |

**Internet on + on-demand polling is a distinct and better mode than internet off.** His correspondents
are not blocked — their envelopes land and wait — and he still never appears in the push registry.
Internet *off* blocks the sender. The on-demand version is strictly friendlier and gives up nothing.

**Cadence can never be a promise.** APNs throttles on its own adaptive budget, fan-out to N devices takes
real time, and function cold starts add more. Thirty minutes is a target, not a contract. **The UI must
never print a number the system cannot keep.**

**Convergence worth exploiting: the manual "check now" action is not a privacy feature, it is the
reliability escape hatch for everyone.** It belongs in the main interface for all users rather than
buried in settings, because it is the honest answer to *"why haven't I got my message yet."*

**Operational consequence — stagger the fan-out, and do it from the start.** A synchronised heartbeat is
a thundering herd: ten thousand pushes at `:00` produce ten thousand simultaneous polls, the worst load
shape and the most expensive, since **concurrency costs money, not request count.** Give each device a
stable random offset within its window. Load flattens, cost drops, and individual polls stop correlating
with a global tick — a small traffic-analysis benefit for free. Cheap now, awkward to retrofit.


### J14. Mesh relay is reciprocal — carry, or you are not carried. — *Don's principle, deferred with mesh*
Don: *"if we ever go live with mesh and that's still an open question it's like read receipts in whatsapp
- if you don't host you don't get to be hosted either."*

**The analogy carries a benefit beyond the mechanism: users already understand this shape.** WhatsApp
trained everyone on "turn it off and you do not get it either." Reciprocity schemes usually fail on
comprehension before they fail on design, and this one arrives pre-explained.

**What it can be here — and it fits the architecture rather than fighting it.** Binary, local and free:
the encounter handshake carries a bit saying whether each node relays. If a peer says no, we decline to
carry for it. No server, no identity, no reputation system.

**What it cannot be.** Not *"he carried my traffic, so I will carry his."* Sealed sender means a relay
cannot tell whose envelope it holds, so per-peer accounting is not computable. And opportunistic
encounters provide no repeated game — a given node may be met once, ever, which is exactly the condition
under which tit-for-tat stops working. Enforcement is therefore at the level of **does this node relay at
all**, and nothing finer.

**It is forgeable** — a modified client sets the bit and carries nothing. **Accept this rather than solve
it:** the cost of being lied to is one encounter's worth of relay, and hardening it would require the
identity infrastructure the product exists to avoid.

**The real caution is legal, not technical, and belongs to `LEGAL-RISK.md`'s class of concerns.** If
relaying is genuinely optional, a user chose to carry strangers' encrypted data. If it is *effectively*
mandatory — opt out and the app stops working — then **every paying customer carries other people's
traffic whether they considered it or not.** *"I paid $9.99 and it stores strangers' messages on my phone
and drains my battery"* is a plausible review and a more plausible complaint.

**Survivable if the bargain stays honest:** bounded storage, bounded battery, visible in the UI, and a
real choice with a clearly stated consequence — the read-receipt bargain rather than a hidden one. **It
stops being defensible if opting out quietly degrades the app to uselessness while the setting still
presents itself as optional.**

**Status: deferred with mesh itself (`J1`).** A principle to hold, not something to build.


### J15. Three pairing methods, and why the third needs different machinery. — *Don's requirement*

> **Partly superseded by `J40`: the NFC route is impossible and is not being built.** Android removed
> peer-to-peer NFC in Android 10 and iOS has never exposed it, so no NFC path exists between two
> phones. QR and the shareable code stand; QR turns out to be the only proximity channel available
> between any two phones. Nothing below about NFC should be implemented.

Don: *"we want NFC pairing and QR code pairing and maybe a 3rd option that could be a code to be read or
photographed or texted via another app if desired."*

**All three must satisfy `A7`: the server is NEVER in the key-distribution path.** That constraint rules
out the obvious implementation of the third option — a short code that indexes a key held on the server
— no matter how convenient it would be.

| Method | Channel | Carries |
|---|---|---|
| **QR** | physical, in person | the full public key |
| **NFC** | physical, tap | the full public key |
| **Text code** | **insecure** — SMS, iMessage, any other app | the full public key, in base32 |

**QR and NFC are secure because the channel is physical** — someone must be standing in front of you.
Nothing further is required.

**The text code cannot assume its channel is private.** SMS and iMessage are readable by the carrier, by
Apple, and by anyone holding either phone. **Therefore the code must contain nothing secret.** An X25519
public key is 52 base32 characters: long to read aloud, trivial to paste into a message. An eavesdropper
learns a public key, which is what "public" means.

**What the insecure channel still permits is substitution** — an attacker who can rewrite the message can
swap in their own key. **Mitigation: both devices display a short confirmation code derived from the
completed exchange, and the two humans compare it out of band** (aloud, by phone, in person). This is
Signal's safety-number model. It needs no server, adds no new cryptography, and its failure mode is
visible: the codes differ.

**Alternative considered and not chosen: a PAKE (e.g. SPAKE2).** A genuinely short spoken code — six
characters — is safe under a PAKE because an attacker gets one online guess and no offline dictionary
attack. More elegant for reading aloud, meaningfully more cryptography to implement correctly.

**Decision: ship the public-key-in-the-code form with an out-of-band confirmation code.** Less code, a
failure mode a user can see, and "compare these words with her" is already familiar from Signal and
WhatsApp. Revisit the PAKE if a spoken-length code becomes a real requirement.

**Consequence for the App ID:** NFC pairing requires the NFC Tag Reading capability on
`com.channelmessenger.app`, which must be enabled at registration.


#### J15 addendum — splitting the code across channels. *Don's proposal, accepted, with the reason corrected*
Don: *"build the code in such a way that it could be split up and sent in parts through more than one
channel, and that way every channel would have to be compromised to lose the code completely. You just
don't wanna make it too complicated to be useful."*

**Correct conclusion, different reason — and the real reason is stronger.**

**Splitting does not protect secrecy.** The code carries a *public key*; an eavesdropper who intercepts
the whole thing learns nothing they could not learn standing next to the recipient's phone. There is
nothing to "lose."

**Splitting protects integrity, which is the attack that actually matters.** The threat is
substitution — an attacker who can rewrite the message swaps in their own key and sits in the middle
permanently. Against a split code they must control **every** channel at once and rewrite all parts
consistently. Compromise one and the reassembled key fails its checksum and pairing is refused. So
Don's sentence is right: every channel must be compromised. To *forge*, not to read.

**Advantage over the out-of-band confirmation code: no synchronous step.** The Signal-style
compare-these-words check works, but both people must be talking at the same moment. Half by iMessage
and half by another app can be sent at midnight to someone asleep and completes when they wake. For
Don's stated case — *"your kid that lives 500 miles from here… somebody you trust and probably already
have several ways of communicating with"* — the multiple channels already exist, and this is better
ergonomics.

**It is nearly free to build, which answers the complexity concern.** Not a protocol change: the payload
is the same bytes. A share sheet emits "part 1 of 2" and "part 2 of 2"; the receiving side accepts parts
in any order and reports when it holds them all. Each part carries its index, the total, and a checksum
over the whole. **The user never counts, splits or reassembles anything — if they must, it is built
wrong.**

**A good property falls out.** An attacker holding one channel can *break* pairing by corrupting their
half, but cannot *forge* it. Denial rather than impersonation, and visible rather than silent.

**Final shape: three delivery methods (QR, NFC, code) and two integrity checks (out-of-band confirmation
code, split channels) that compose freely.** One channel plus a verbal comparison; or two channels and
no call; or both. The split must be generated by the app, never left to the person.


### J16. There is no device migration, and that is currently an accident. — *OPEN, needs Don*
Found while auditing what the app writes to disk, 2 September 2026.

**The bug that was fixed.** `MessageStore` wrote every message plaintext and every contact's derived
keys as plain JSON to Application Support, with no backup exclusion. **iCloud Backup is on by default**,
so a user's entire conversation history would have uploaded to Apple, and an unencrypted Finder backup
would have put the same plaintext on whatever computer the phone was last plugged into. That falsifies
the one claim the product rests on — *nothing about your conversation leaves the two phones* — silently,
for every user, by default. The store is now excluded from backup and written with
`completeFileProtectionUntilFirstUserAuthentication` (not full protection, which would make the file
unreadable while locked and break background BLE delivery entirely).

**The consequence, which is now a deliberate design and must be treated as one.** Three things now
combine:
- the identity key is `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` — it never leaves the device
- the message store is excluded from backup
- only `DevicePreferences` (in `UserDefaults`) is backed up

**So a user who replaces their phone loses their identity and every conversation, and must re-pair with
every contact from scratch.** Their settings survive; nothing else does.

**This is correct for privacy and bad for users, and it is not yet a decision — it is a side effect.**
Signal has exactly this problem and solves it with an explicit device-to-device transfer over the local
network, which never touches a server. That is the shape of the right answer here too, and it fits the
architecture: the app already knows how to move data directly between two devices.

**Needs Don:** accept the cliff for v1 and say so plainly in onboarding, or schedule a device-transfer
feature before launch. What must not happen is shipping it unstated and letting a paying customer
discover it when they upgrade their phone.


### J17. Channel indicators: per-message and per-conversation. — *Don's design*
Don: *"a simple small color icon at top of or under each message similar to the whatsapp check marks but
where color indicates the send channel and also at top of screen the same colors can show what channels
are available at the moment - dim but color visible if not active… this would be chat specific as bt
might be good for some chats and not for others more distant."*

**Two displays sharing one colour vocabulary.**

**Per message**, in the position WhatsApp uses for its check marks: which pathway actually carried it,
plus delivery state. This is information no other messenger can show, because no other messenger has
more than one pathway.

**Per conversation**, at the top of the screen: which pathways are available *to this contact right
now*. Lit when usable, dim-but-still-coloured when not — so the colour teaches the vocabulary even when
the channel is unavailable, rather than the icon vanishing and leaving nothing to learn from.

**Why per-conversation and not global.** Don's point: Bluetooth is a property of a *pair*, not of the
device. A contact in the next room is reachable by radio; the same user's contact three states away is
not, at the same instant, on the same phone. A global "Bluetooth: on" indicator would be actively
misleading. This falls out of `J8` — each contact already carries its peer's declared pathways.

**Three channels, three colours:** direct Bluetooth, direct Wi-Fi, internet mailbox.

**What this gives the product that nothing else has.** Every other messenger hides its transport
because it only has one. Showing the pathway makes the central claim *visible* rather than asserted: a
user watching messages go out over Bluetooth can see for themselves that nothing touched the internet.
It converts a privacy promise into an observation.

**Honest limit on delivery state.** Don: *"whether received if we can know that."* We can, but only on
the direct paths, where the receiving device acknowledges over the same connection. On the mailbox path
the sender learns only that the envelope was *collected* (`J6`'s receipt check) — never that it was
read, since a read receipt would have to come back through the blind relay and there is deliberately no
identified return path. **The interface must not imply certainty it does not have.**

**Status: designed, not built.** Depends on the transport reporting which pathway carried each envelope,
which it does not yet surface.


### J18. The wake-up rate IS the check frequency. They were modelled as two questions and are one. — *Don's correction*
Don: *"the question is not how often we respond, that answer is the choice above - the answer here is how
often the server sends you the wake up for background checks… if someone wants to check in background
every 15 min then the server needs to wake every 15 m and someone selecting less often will unload the
server so that's a good thing and isn't a matter of respond or not as we'll respond with a pull."*

**He is right and this corrects `J10` and `J13`.** The interface offered two dials — a check cadence and
a separate "how do you handle wake-up pings" — as if they were independent. They are not. A device that
wants a background check every fifteen minutes needs the server to wake it every fifteen minutes. There
is no meaningful third state where a wake arrives and is declined; the device always answers by polling.

**One setting, three layers:**

| Layer | Behaviour | Configurable? |
|---|---|---|
| **Foreground** | poll every minute or two | No. The app is open; the user is waiting. |
| **Background** | the user's chosen frequency, and the server wakes at that rate | **Yes — this is the setting** |
| **Manual** | "Check now" | Always available |

**Quiet hours.** Don: *"could have option to not be waked between 10p and 8a."* A checkbox, not a
schedule editor. Most people do not want to be woken at 3am, and every suppressed wake is battery saved
on the device and load never generated on the server.

**Cheaper for everyone, which is the elegant part.** `J11` established that suppressing one heartbeat
suppresses every poll it would have caused. Under this model a user who chooses a slower cadence is
*automatically* cheaper to serve — the saving is a direct consequence of their own preference rather
than something the operator has to engineer. **Interests align instead of competing.**

**One property of `J10` is weakened, and it is worth stating.** The blind heartbeat's value was that it
was identical for everyone and therefore carried nothing. Per-device rates mean the server learns each
device's cadence. But `J11` already accepted cadence as the one axis with a server-visible shadow, so
this reveals nothing new — and it is still true that a heartbeat never says whether mail is waiting.
**Keep cadence options coarse**, for exactly the reason `J11` gives.

### J19. Relay copy: shorter, and named for what it does. — *Don*
Don: *"the explanation for relay is too wordy… call it Relay Messages and explain this is for you and
for others to extend range of off-grid communication when possible."*

**Label: "Relay Messages."** The `J14` bargain still has to be visible — you carry for others, others
carry for you — but in a sentence or two, not a paragraph. *"Extend range for everyone"* is the idea;
the reciprocity is the mechanism, and the mechanism does not need three clauses to explain.


### J20. Multiple devices, and messaging yourself. — *Don's question, design proposed*
Don: *"can a user send a message to himself?… if a user wants to create the same account on another
owned device require NFC or QR for that and if they don't link to their other device at first launch then
they get a unique account on that device… think about how that whole flow should work to give
flexibility while still secure."*

**The tension.** Identity here is a keypair on a device and there is no server-side account, so "the same
person on two devices" has no natural representation. Every mainstream messenger solves this with an
account. We deliberately do not have one.

#### Three ways it could work

**A — Each device is its own identity; your devices pair with each other like any contact.**
Fits the architecture exactly and needs no new cryptography. Messaging yourself falls out for free: your
phone and tablet are paired contacts, so you can send between them. The cost: a contact who paired with
your phone does not know your tablet exists, so their messages reach only the phone.

**B — Copy the private identity to the second device by QR or NFC.** Both devices then *are* the same
identity, so contacts reach both. **Rejected.** Two devices sharing one identity both consume the same
rotating tokens and the same replay-protection state, so they would fight: a message opened on one
becomes a duplicate the other cannot distinguish from an attack. And exporting a private key through a
QR code creates exactly the attack surface `J15` was written to avoid — the pairing code deliberately
carries only a *public* key, and this would break that invariant.

**C — A primary identity that signs per-device subkeys.** Signal's model. Correct, and the only option
that gives true multi-device. It is also a protocol change to the identity layer, and identity is the
hardest thing to retrofit.

#### Proposed: A now, with a concept that makes C unnecessary for most people

**Devices are contacts, and some contacts are yours.** At first launch a device generates its own
identity and takes a name — the user's, or one we generate so two devices are never confusable. Pairing
your own two devices uses the same NFC or QR flow as any other pairing, with the same confirmation code,
so there is no second security path to get wrong.

**What makes it more than a workaround: mark a contact as "my device", and the app mirrors your
conversations to it over direct radio whenever the two are in range.** A phone and a tablet in the same
house are in range most of the time, so they stay in sync without a server, without an account, and
without any change to the identity model. Sync becomes another thing that happens opportunistically over
the radio — which is what this product already is.

**Honest limitation, and it must be said in onboarding rather than discovered.** A contact who paired
with your phone reaches your phone. Your tablet learns the message by mirroring, not by receiving it
directly, so if the two are never near each other the tablet stays behind. Someone who wants both
devices reachable independently pairs with both.

**Messaging yourself works either way** and is worth having on its own: a queued note to self is a
genuinely useful thing, and it is the easiest way for one person to test that the app works at all.

**Status: proposed, not built.** Needs Don's agreement on A-plus-mirroring versus committing to C. If C
is ever wanted, decide before v1 ships — retrofitting an identity model onto installed users is the one
migration this architecture has no good answer for (see `J16`).


### J21. Wi-Fi: a pecking order of pathways, ranked by exposure. — *Don's design*
Don: *"implement wifi via shared lan (no internet exposure) if a paired device can be exposed if the lan
permits it - that's one level. then if direct wifi to the device is available that takes priority over
using the shared lan since a sniffer can see shared lan access and direct to device maybe not… several
paths to set up with a pecking order in order of security and the app settings can disable or enable
each method so they get skipped in the pecking order."*

#### The ladder, most private first

| Rank | Pathway | Who can observe it | Speed |
|---|---|---|---|
| 1 | **Direct Wi-Fi** (Wi-Fi Direct / Wi-Fi Aware) | someone with a radio in range | fast |
| 2 | **Bluetooth LE** | someone with a radio in range | slow (KB) |
| 3 | **Shared LAN** | anyone on that network, **and the router owner** | fast |
| 4 | **Internet mailbox** | the relay, by design blind | fast |

**Why direct outranks LAN, which is Don's point and it is correct.** Both are local, but a shared LAN
means somebody else's infrastructure. A router logs. A corporate network inspects. On a café or hotel
network every other guest is a peer. Direct Wi-Fi touches no infrastructure at all — the observer has to
be physically present with a radio, which is a much smaller and more expensive set of adversaries.

**Why Bluetooth sits above shared LAN despite being slower.** Same reasoning: BLE touches nobody's
equipment. Ranking is by exposure, as Don asked; speed breaks ties within a rank, not across them.

**Every rung is individually switchable, and a disabled rung is skipped rather than failed on.** The
ladder is a preference order, not a requirement chain.

#### Answering Don's question directly: can a phone on Wi-Fi still reach a direct Wi-Fi contact?

**Generally yes, with a caveat.** Modern chipsets support concurrent operation — the radio time-slices
between the infrastructure connection and the peer-to-peer link. This is precisely how AirDrop works
while an iPhone is on Wi-Fi. On Android, Wi-Fi Direct and Wi-Fi Aware both usually coexist with an
active AP connection.

**The caveat is real: on some hardware, establishing a peer link degrades or briefly drops the
infrastructure connection.** It is chipset-dependent and not something we can promise. *Believed, not
measured — this needs testing on Don's actual devices before any of it is claimed to a user.*

#### Platform reality, and where the cross-platform gap actually is

| | Shared LAN | Direct Wi-Fi |
|---|---|---|
| **Android ↔ Android** | works — standard IP | Wi-Fi Direct since 4.0, Wi-Fi Aware since 8 |
| **iPhone ↔ iPhone** | works — standard IP | MultipeerConnectivity over AWDL |
| **iPhone ↔ Android** | **works — it is just IP** | **the hard case** — AWDL is proprietary and never talks to Android; Wi-Fi Aware in iOS 26 is the first standards-based route and is unverified |

**This is the finding that should drive sequencing. Shared LAN is the only Wi-Fi pathway that already
works in every direction, including the cross-platform case, using nothing exotic** — mDNS for discovery
and ordinary sockets for transport, with `NsdManager` on Android and `NWBrowser`/Bonjour on iOS.

**Therefore: build shared LAN first.** It is the cheapest to implement, the only one that is
cross-platform today, and it delivers the largest practical gain — two phones on the same home or office
Wi-Fi get fast transfer without the relay and without waiting for a radio encounter. Direct Wi-Fi is
platform-fragmented and the interesting half of it is unproven.

**iOS requires the Local Network permission** (`NSLocalNetworkUsageDescription` plus declared Bonjour
services). It prompts on first use. Worth knowing that a privacy-focused app asking to see the local
network needs its explanation written carefully.

#### Hotspot as a last resort

Don: *"even if one device has to be a hot spot at times."* Technically it closes the cross-platform
direct gap, but neither platform lets an app enable a hotspot or join a network programmatically. It can
only ever be a **guided manual flow** — "ask them to turn on their hotspot, then join it" — so it belongs
as an explicit user action, never in the automatic pecking order.

**Status: designed, not built.** Shared LAN first; direct Wi-Fi after; hotspot only if the cross-platform
case proves to matter in real use.


### J22. Never call it an account without saying what kind. — *Don*
Don: *"the thing about 'accounts' even though accounts are device based and not server based by design so
when an account is created the app and docs need to make that distinction clear."*

**The word "account" carries an assumption that is false here**, and it is the assumption most users
will bring: that something was created *somewhere else*, on a company's server, holding their details and
recoverable if the phone is lost. None of that is true. Nothing was registered anywhere. No server knows
this identity exists.

**Preferred term: "identity", qualified on first use as belonging to the device.** Where "account" is
genuinely the clearer word for a lay reader, it must be immediately followed by what makes it different.

**What must be said at the moment one is created**, because this is when the user forms their mental
model and every later surprise traces back to it:
- It exists only on this device.
- Nothing was sent anywhere and nobody was told.
- There is no password and no recovery, because there is nobody to recover it from.
- Losing the device loses the identity (`J16`) — this is the direct consequence and users must hear it
  here, not discover it later.

**This is a real feature, not a caveat to be buried.** "You did not create an account — nothing about
you left this phone" is the strongest single sentence the product has. Every other messenger opens by
asking for a phone number. Say plainly what did not happen.

**Applies to:** first-launch copy, the identity area of Settings, both store listings, the privacy
policy, and `docs/ChannelMessenger-How-It-Works.docx`. Also `J20` — a second device gets its **own**
identity, and the naming there ("Don's iPhone", "Don's iPad") only makes sense once a user understands
these are per-device rather than one account in two places.


### J23. The LAN pathway ships off by default. — *Found while building `J21`*
Building shared LAN surfaced an identifier **neither platform lets an app suppress.**

A DNS-SD registration carries an `SRV` record, and an `SRV` record names a host. Both `NWListener` and
`NsdManager` publish the system's `.local` hostname there, and no API exposes that field. On iOS that
hostname derives from the user-set device name. Suppressing it would mean writing a raw mDNS responder
and abandoning both frameworks.

**The advertised instance name is clean** — a rotating opaque value, pinned by a test that scans the
disclosure for every identifier the process can reach and fails naming whichever leaked. That test was
deliberately broken to confirm it is not vacuous. **The hostname beside it is not ours to control.**

**The practical effect: joining any network announces "this named device runs Channel" to everyone on
it.** On a home network that is nothing. On a café, hotel, or office network it links the app — and by
extension the fact that you use it — to a named person, on every network you ever join.

**So this rung is OFF until asked for**, unlike Bluetooth and the mailbox. Everything else in the ladder
either touches no infrastructure or is deliberately blind; this one hands a name to strangers.

**Note this is a different question from the one Don already settled.** He judged that Apple or Google
knowing an app is installed is not much of a security problem, and that is reasonable — they sold it to
you. This is a stranger on a café network learning it, tied to a device name. Different observer,
different bar.

**When it is worth turning on, and the setting copy should say so:** you and a contact on the same
trusted Wi-Fi, where it is dramatically faster than Bluetooth and works iPhone-to-Android, which no
other direct pathway does today.

### J24. Two consequences of shared LAN worth recording plainly

**Android now requests `INTERNET`, and a guarantee is gone.** There is no local-network-only socket
permission below targetSdk 37, and a TCP connection to a phone on the same Wi-Fi needs `INTERNET`, which
does not distinguish LAN from internet. **The manifest can no longer prove absence of network access**,
which it previously could. What remains is weaker but real: the only socket code in the app is the LAN
transport, dialling only addresses `NsdManager` returned for our own service type. The reasoning is
recorded in the manifest rather than the old note being quietly deleted. No location permission was
added; moving to targetSdk 37 will require `ACCESS_LOCAL_NETWORK`, and the manifest says so.

**Peer authentication does not use presence tokens, and could not.** The specification asked for a
presence-token match, but `their_presence_token` is not on the FFI surface — `presenceTokens` returns
*our own* set, the values a peer uses to recognise us, and the direction byte guarantees the two never
coincide. So acceptance instead requires that a sealed envelope from the peer **opens** under a known
contact's keys, which proves possession of the message key rather than the routing secret. That is the
stronger property: `PROTOCOL.md` §4.6 says the routing secret is replayable and is "a hint, never
authorisation." Until a peer authenticates it receives no envelopes and does not appear as visible.

---

### J25. The app should behave like a messenger people already know. — *Don*

Recorded 2 Sep 2026, from Don, while the Play closed-testing setup was in progress. Design intent,
not yet built. The through-line: **a messenger has to work the way users expect a messenger to
work.** Everything distinctive about this product is additive to that baseline, never a substitute
for it.

#### The baseline, following WhatsApp's model

- **Contacts and conversations are separate lists.** A contact list, and a list of active or
  archived conversations. Today the app conflates them; it should not.
- **Archive or delete an active conversation**, and **delete a single message**.
- **The contact list is our paired contacts**, created inside the app by pairing — not an import of
  the phone's address book. Pairing remains the only way someone becomes a contact.
- **Selectable notification sounds**, as every mainstream messenger offers. Added by Don 2 Sep 2026,
  and it belongs in this list rather than a feature list of its own: it is part of "works the way
  users expect a messenger to work," not a differentiator. Per-conversation overrides are the
  obvious follow-on, but the baseline is one choosable sound.

#### Messaging yourself / your own devices

The user's own account should be **automatically recognised as paired**, so messaging yourself works
without a pairing ceremony against your own identity. This is the user-facing half of `J20`, which
covered the multi-device key model but not this affordance.

#### The two additions that are ours, not WhatsApp's

1. **Per-message send method, kept with the message.** Each message records and shows which channel
   carried it. This is `J17`; Don restated it here as a requirement, not a nice-to-have — the
   indicator is permanent per message, not a transient status.
2. **A live channel bar at the top of an open chat.** Whenever a conversation is on screen, show
   which channels can reach that contact *at this moment*. Not a static capability list — a live
   readout of present reachability.

Together these are the one thing on screen no competitor can show: not just that a message was
delivered, but by which path, and what paths are open right now.

#### The familiarity test — added 2 Sep 2026 after the first device session

> "try to make this function more or less the way WhatsApp interface does, and that way our users
> will arrive already knowing how to use the app rather than having to learn a new flow that is more
> cumbersome than what they're used to"

This gives the section a decidable test rather than a sentiment:

**Where our flow differs from WhatsApp's without a reason recorded in this file, the difference is a
bug.**

That is deliberately strict, and it cuts against the instinct to improve things. A messenger's
interface is not where originality pays: the user arrives with a decade of muscle memory and every
deviation spends it. The differences worth keeping are the ones that exist *because* of what this
product is — the channel indicators (`J17`, `J27`), pairing instead of a phone-number directory
(`J25`), the paired/unpaired distinction (`J30`). Each is recorded with its reason. Everything else
should feel like an app the user already knows.

Three failures found in a single session on a real device, each of which the test would have caught
before it shipped:

- **No way out of a conversation.** No back affordance at all — the user was trapped.
- **Tapping a contact opened a detail screen with a Message button**, one tap more than every
  messenger, to reach the thing the tap obviously meant.
- **Channel indicators stacked vertically in bordered, colour-filled boxes with explanatory text**,
  where the intent was a quiet horizontal row — "there but not too much in your face."

None was a hard problem. All three came from designing the screen rather than recognising it.

#### Optional phone-contact access — and the conflict it creates

Don's case: someone you want to pair with is **not physically present**, so QR and NFC are
unavailable, and you need to send them a pairing code plus a link to download the app. Reading the
phone's contacts would let the user pick that person and send by email or SMS.

**This directly contradicts two shipped documents and must not be built without changing them
first:**

- `store/privacy-policy.md` §2 ("Information we do not collect, ever") and §9 (permissions) both
  state the app cannot read the address book.
- `store/data-safety.md` — Contacts: "**Not collected.** No contacts permission is requested"
  (citing `A2`, `G1`).

If contact access ships, both must be updated **in the same change**, and the Play Data Safety form
resubmitted. The honest framing, if built: access is optional, requested only at the moment the user
chooses to invite someone, used solely to address that one invitation, and nothing from the address
book is stored, uploaded, or retained. Anything less specific than that would make the privacy claim
this product rests on into a lie. Not a reason to refuse the feature — a reason to do it precisely.

#### Standing task: download links go stale

Invitations carry links to install the app. Those targets change as status changes — closed beta
now, open beta later, public store listings after that. **Whoever changes the app's release status
owns updating the invite links in the same change.** Recorded here so it is not discovered by a
recipient clicking a dead link.

---

### J26. "Relay" means a phone. The Netlify service is the "server". — *Don*

Settled 2 Sep 2026, after the word had drifted across three different things in one conversation.
Recorded because the collision is not cosmetic: two of the three senses have **opposite privacy
stories**, and a user reading the settings screen had no way to tell which one the store listing
meant.

| Term | What it means | Status |
|---|---|---|
| **Relay** | Using another *phone* as a Bluetooth and/or Wi-Fi range extension | Not built (`J1`) |
| **Server** | The Netlify service: internet storage for messages not yet pulled by their receiver | **Live** at `channelmessenger.netlify.app`, deployed 2 Sep 2026 |
| *(unnamed)* | A macOS/Windows desktop app acting as a household relay | Undecided, and probably not worth it — see below |

#### Why the distinction matters

The **server** is something you send *through*: a blind mailbox that holds a sealed envelope until
its recipient pulls it. It is "sort of a relay" in the loose sense, but calling it that invites
exactly the confusion this entry exists to end.

A **relay** is a phone volunteering to *carry for others*. That is a different act with a different
privacy cost, and it is the thing the settings screen's reciprocity copy is about.

Using one word for both makes "your phone can carry for others" and "your message waits on our
server" read as the same feature. They are not.

#### Consequences

- New code addressing the Netlify service is named `Server*`, never `Relay*`. The persisted
  `DeliveryPathway.internetMailbox` enum case keeps its name — it is `Codable` and stored on
  device; renaming it would break decode of existing conversations for no user-visible gain.
- The existing in-app "relay" reciprocity copy stays as-is. It was always about the phone sense,
  which is the sense that keeps the word.
- `server/README.md` and the store listing should stop calling it a relay. Not yet done.

#### The desktop case, deferred

A macOS/Windows app *could* act as a household relay, but a house almost certainly has Wi-Fi
already, so the relay half may earn nothing. Don's read: **the client half is worth doing, the relay
half may not be** — and neither is critical to basic function. Deferred, not rejected.

---

### J27. The channel indicator system: live lights above, permanent marks below. — *Don, colours proposed here*

Extends `J17` (per-message channel indicator) and `J25` (live channel bar). Design intent, not built.

Two related displays that must agree with each other:

1. **Status lights at the top of an open conversation** — LIVE. What can reach this contact *right
   now*. A light illuminates when that channel is available and goes dark when it is not; the
   Bluetooth light goes out when the contact walks out of range, without the user doing anything.
2. **A mark on each message** — PERMANENT. Which channel actually carried that message, in the
   position WhatsApp puts its check marks. This never changes after the fact; it is a record.

The point of pairing them: the lights tell you what is possible now, the marks tell you what
happened then, and they use the same colour vocabulary so the connection is obvious without a
legend.

#### Colours

| Channel | Colour | Note |
|---|---|---|
| Bluetooth | **Blue** | Don |
| Wi-Fi | **Green** | Don |
| Server (internet) | **Violet** | Proposed here — see the collision below |

**Don initially suggested blue for internet as well as Bluetooth.** Flagged and changed: those two
are the *most* and *least* private rungs of `J21`'s ladder, so they are precisely the pair that must
never be confused at a glance. Violet was chosen over amber/orange because Don has twice rejected
orange tones as clashing with the garnet brand ("that pink/orange clashes with the garnet"), and
violet stays cool-toned alongside blue and green while remaining unmistakable from both.

#### Distinguishing within a colour

Two cases need a finer grain than one hue each:

- **Direct Wi-Fi vs shared LAN.** Both green, but they sit on different rungs of `J21` — direct
  Wi-Fi touches no infrastructure, shared LAN means a router that logs and other guests who are
  peers. Proposal: **filled = direct, hollow/outlined = LAN.** Filled reads as "solid, nothing in
  between"; hollow reads as "something in the middle." That maps the exposure difference onto the
  shape rather than requiring a second colour.
- **Bluetooth direct vs Bluetooth via a relay phone.** Don's suggestion: a ring around the dot, or a
  small dot beside it. Proposal: **a ring**, because a satellite dot is easy to mistake for a second
  channel being available. A ring reads as "the same channel, one step removed," which is exactly
  what a relay is. (Relay in the `J26` sense — another *phone* extending range. Not built.)

#### The rule this system must not break

Filled/hollow and ringed/unringed encode **exposure**, never signal strength or speed. If a future
change makes a hollow indicator mean "weak signal," the whole vocabulary stops meaning anything and
the user learns nothing from it. Same discipline as `J21`'s ranking: exposure, not performance.

#### Measured palette (added after Don asked about contrast on the garnet banner)

The lights sit **on** the garnet app bar (`#6F131B`), so these were measured, not chosen by eye.
WCAG requires 3:1 for a UI graphic; all three clear 4.5:1 comfortably.

| Channel | Colour | Contrast on garnet |
|---|---|---|
| Bluetooth | `#4FC3F7` | 5.87:1 |
| Wi-Fi | `#69F0AE` | 8.22:1 |
| Server | `#FFD54F` | 8.34:1 |

**Amber replaced the violet proposed above**, on evidence rather than taste. Simulating deuteranopia
and protanopia, violet collapsed toward both blue (1.19:1 normal vision) and green (1.19:1 protan) —
the server indicator became ambiguous against the exact channels it most needs to be distinct from,
since server and Bluetooth are opposite ends of `J21`'s exposure ladder. Amber separates cleanly from
both under every simulation tested (2.66–3.30 vs Bluetooth, 1.95–2.44 vs Wi-Fi).

This amber is a small indicator dot. It is **not** the saturated orange square Don rejected for the
app icon, and it is not the pink/orange that clashed with the garnet — both of those were large
fills; this is a 
few-pixel light on a dark ground.

#### Colour must never be the only signal

Measuring exposed a defect no colour choice fixes: **Bluetooth blue and Wi-Fi green sit at 1.35:1
under deuteranopia** — a red-green colour-blind user cannot reliably tell them apart, in any palette
tried.

So each channel gets a **distinct glyph** — a Bluetooth mark, Wi-Fi arcs, a globe for the server —
and colour reinforces the glyph rather than carrying the meaning alone. This is WCAG 1.4.1 (Use of
Color) regardless, but here it is also a product requirement: `J17` and `J25` exist so the user can
tell *at a glance* which path carried a message. An indicator that ~8% of men cannot read is not
doing that job.

The shape modifiers from the section above (filled vs hollow for direct Wi-Fi vs LAN; a ring for a
relayed Bluetooth route) sit on top of the glyphs and inherit the same rule: shape encodes exposure,
never signal strength.

#### A letter under each light — the TitleVitals pattern

Don, on seeing the colour-blindness measurement: reuse what already works in TitleVitals — put a
**letter beneath each light**. `B` Bluetooth, `W` Wi-Fi, `I` internet.

This supersedes the glyph proposal above as the *primary* redundant channel. A letter is smaller,
unambiguous, needs no legend, and does not have to be redrawn per state the way a glyph does. Colour
becomes pure reinforcement: remove all colour and the display still reads correctly, which is the
test that matters.

Note the user-facing letter is `I` for internet, not `S` for server. `J26` governs what we call the
*service* in code and documentation; a channel indicator names the *path from the user's point of
view*, and to a user that path is "the internet."

#### The lights are per-conversation and continuously changing

Stated plainly because it constrains the implementation: **which lights are lit depends on who you
are chatting with, and changes minute to minute as people move.** These are not device-level
capability lights ("this phone has Bluetooth"). They answer a narrower question: *can this specific
contact be reached by this specific path, right now?*

Consequences:

- State is per-contact, recomputed while a conversation is open, not global app state.
- Lights must extinguish on their own when a contact walks out of range — driven by transport
  reachability changing, never by the user re-entering the screen.
- Two conversations open at different moments can legitimately show different lights for the same
  device. That is correct behaviour, not an inconsistency to smooth over.
- Whatever backs this must tolerate rapid flapping at the edge of range without the bar flickering
  distractingly — some hysteresis on the way to "off" is expected, and it belongs in the transport
  layer's reachability reporting rather than in the view.

---

### J28. Every envelope is sealed `LocalOnly`, and the server is a gateway hop. — *Found by the Android agent, 2 Sep 2026*

**Open. Not a blocker for testing; a conformance defect that must be closed before release.**

`node.rs` hard-codes `TransitPolicy::LocalOnly` on every envelope it seals (lines 354, 368). There is
no parameter on `seal` to request `AnyTransport`. The value sits in the AAD, so it cannot be changed
after sealing — a sealed envelope's policy is fixed for its whole life.

`PROTOCOL.md` §2.2 is unambiguous about what that value means:

> | 0 | **Local only.** MUST NOT traverse an internet gateway hop. **Default.** |

The server (`J26`) is exactly that hop. So as built, the internet pathway carries envelopes that the
protocol says must not take it.

#### Why it does not break anything today

Verified by inspection of every `TransitPolicy` reference in `mesh-core-rs`: the value is **set** and
**parsed**, and never **enforced**. Nothing rejects, refuses, or reroutes on it, on either platform
or on the server — which never parses an envelope at all. Internet delivery therefore works, and the
defect is invisible at runtime.

That invisibility is the danger. A rule nothing checks is a rule that rots quietly, and this one is
load-bearing: it is the mechanism by which a user's "keep this off the internet" intent would ever be
honoured. Today that intent has no teeth anywhere in the stack.

#### The options, and the trade

1. **Add a policy parameter to `seal`.** Correct. The caller chooses, internet-bound envelopes are
   sealed `AnyTransport`, and the field starts meaning something. Cost: it touches the AAD, so
   conformance vectors regenerate and both FFI surfaces change. This is the real fix.
2. **Weaken `PROTOCOL.md` §2.2** to make the flag advisory, or redefine the server as not a gateway
   hop. Cheap, and dishonest — it resolves the contradiction by lowering the claim rather than
   meeting it, on precisely the guarantee this product is sold on.
3. **Ship the beta knowingly non-conformant**, fix before release. Where we are now, recorded here so
   it is a decision rather than an oversight.

Option 1 is the answer. Option 3 is acceptable only while it stays written down.

#### The related gap

`DeliveryPolicy` (`J8`) is equally unenforced: nothing writes a policy at pairing, so every contact
carries `DeliveryPolicy.unknown`, in which every pathway is false. Both platform agents found this
independently and both correctly declined to enforce it in the server rung alone — doing so would
have made the pathway refuse every contact on every device and read as a bug. Enforcement belongs
above the transport seam, once pairing actually writes a policy.

---

### J29. Too much explanation for too little information. — *Don*

Stated 2 Sep 2026, looking at the shipped iOS settings screen. A standing editorial rule for all
user-facing copy, not a note about one screen.

The offending passage was ~90 words below the Internet Mailbox toggle explaining how all three
pathways compare. Two failures at once, and the combination is what makes it bad:

1. **It explained at length.** Ninety words of body copy as chrome under a switch.
2. **It informed very little.** It said turning the mailbox off means "you never appear on our
   server at all" — the privacy upside — and never said the cost: with it off there is **no internet
   path at all**, so you receive messages only when physically near the sender. A user could switch
   it off to be private and silently stop receiving anything from anyone out of radio range.

Length was not the real sin. **A shorter version of the same one-sided sentence would still be
wrong.** The rule is not "write less"; it is "carry more per word."

#### The rules this produces

- **Every toggle states what you gain and what you give up**, in that order, one short line each.
  A setting that describes only its benefit is a half-truth, and half-truths about privacy
  trade-offs are exactly what this product exists not to tell.
- **A general explanation never sits under a specific control.** In both SwiftUI and Compose that
  position reads as *that control's* footer. Section-level material goes at the top of the section
  or behind a disclosure.
- **Reference material is disclosed, not displayed.** The full comparison of the pathway ladder is
  worth having; it is not worth showing on every visit to Settings.
- **Readable at a glance, not studied.** If a settings screen needs reading rather than scanning,
  it has failed regardless of how accurate it is.

#### Why this is not a licence to be vague

The obvious wrong reaction is to cut until the copy stops making claims. That would break `J22`
(never call it an account without saying what kind) and the whole `store/privacy-policy.md` posture.
The product's differentiator is telling users the truth including the inconvenient half — so the
answer is always **compress, never omit the cost.** Where a trade-off genuinely needs a paragraph,
it goes behind a disclosure with a one-line summary above it.

---

### J30. Two contact sources, one list, and pairing state made visible. — *Don*

Stated 2 Sep 2026. Expands `J25`'s contacts/conversations split into a full design.

- **Two entry points:** a **Channel contacts** button and a **Phone contacts** button.
- **Phone contacts can populate the Channel list, minus the pairing information.** So a person can
  exist in Channel as someone you intend to reach before any pairing has happened.
- **The contact detail page carries a Pair button** offering the routes we support — QR, NFC, or a
  code sent by other means.
- **The list filters: paired only, or all.**
- **Paired and unpaired contacts are visually distinct** — Don suggested colour.

#### Why this is more than a list feature

It changes what a "contact" *is*. Today a contact only exists after pairing, so the presence of a row
proves a shared secret exists. Under this design a row proves nothing of the kind, and the interface
must carry that distinction or it will quietly imply security that is not there.

**Therefore the paired/unpaired distinction is a security signal, not decoration**, and it inherits
`J27`'s rule: colour must not be the only carrier. A colour-blind user must be able to tell a paired
contact from an unpaired one. Use a label or a mark, with colour as reinforcement.

The paired state must be derived from whether contact keys actually exist — never from a stored flag
that could drift out of step with the keys it claims to describe.

#### The compliance consequence, which is not optional

Reading the phone's address book contradicts two shipped documents:

- `store/privacy-policy.md` §2 ("Information we do not collect, ever") and §9 (permissions) both
  state the app cannot read the address book.
- `store/data-safety.md` — Contacts: "**Not collected.** No contacts permission is requested."

**Both must be updated in the same change that ships contact access, not afterwards.** Play's Data
Safety form asks about data leaving the device; if the address book is only ever read locally and
nothing derived from it is transmitted, "not collected" may remain correct there — but the privacy
policy's flat statement becomes false either way, and that is the document the product's claims rest
on. See `J25`, which recorded this hazard when the feature was first raised.

The honest framing, if built: access is requested only when the user chooses to import or invite,
used solely for that, and nothing from the address book is stored, uploaded, or retained.

---

### J31. Settings are self-evident; attachments are gated by physics. — *Don*

Two related instructions, 2 Sep 2026, from reading the shipped settings screen.

#### Settings need no paragraphs — this amends J29

> "if you look at apple settings i don't think you see much of any explanation on any of the items.
> the settings should be obvious and not need a paragraph."

`J29` said carry more per word. This goes further: **the right number of words is usually zero.** The
cadence section's explanatory block was deleted outright rather than shortened, because it "says
nothing useful and potentially misleading" — it opened with "One schedule, not two", a comparison
meaningful only to someone who knew there had once been two, and it claimed "our server wakes your
device", which is false (`heartbeat.mts` is a placeholder).

**The rule: if a setting needs a paragraph to be honest, the label is wrong.** Fix the label. A
control called "About every 15 minutes" needs no footer.

This does not license vagueness. `J29`'s requirement that a trade-off state its cost still stands —
but it must fit in the label or a single short line, and where it genuinely cannot, that is a signal
the control is badly named, not that a paragraph is owed.

**Why it matters beyond tidiness:** Don's reason is density. "there will be more items added of
course to the settings. i want most of the options available in whatsapp as a standard." A screen
that spends a paragraph per control cannot hold a messenger's worth of settings.

#### Attachments, and the bandwidth that decides them

Images and attachments are wanted "at some point". The constraint is measured against what the
protocol already fixes:

- `policy::MAX_ENVELOPE_BYTES = 32_768`, `policy::MAX_FRAGMENTS = 16`. **A message today is capped at
  32 KB.** Text is unaffected; any image exceeds it immediately. Attachments therefore require a
  protocol change, not just a UI.
- BLE throughput, platform-typical and **not yet measured on our own hardware**: ~1–5 kB/s at the
  unnegotiated 23-byte MTU, ~20–60 kB/s once the 517 MTU we request is granted, ~150 kB/s at best.
  Wi-Fi LAN is 1–12 MB/s. **The gap is roughly 100–1000×.**
- The binding limit is not throughput but *duration*. `PROTOCOL.md` §6.2: an encounter window "may be
  ten seconds in a corridor." A 2 MB photo needs 35 s – 2 min of sustained Bluetooth. **Photos over
  Bluetooth are realistic between two phones sitting together, not between people passing.**

So the policy Don proposed is the correct one and is forced rather than chosen: **above a small size
threshold, an attachment requires Wi-Fi or an unmetered connection**, with a metered-cellular setting
because "some pay for data on cellular metered and some are unlimited".

**Open, and worth closing cheaply:** real iOS↔Android BLE throughput varies enormously by chipset and
we have two phones. Measure it rather than shipping a threshold derived from my general figures.

---

### J32. Contact detail, calling, and backups we cannot read. — *Don*

Stated 2 Sep 2026, extending `J25` and `J30`. Design intent, not built.

#### Three screens

1. **Phone contacts** — read **with permission, in-app only**. Don, unprompted: "we will not be
   uploading them to our server of course." That is the whole constraint; see the compliance note in
   `J30`, which still applies and still must land in the same change.
2. **Channel contacts**, filterable by paired, carrying useful fields beyond a name: **company, phone
   number**.
3. **Chats**, active and archived.

#### Calling

A phone number in a contact should be callable — **through the phone's own dialler, not through
Channel.** We have no VoIP infrastructure and building it is not on the table now. Two thoughts Don
raised, both worth keeping:

- **VoIP someday**, worth thinking about how it would be implemented rather than designing it now.
- **Calling via WhatsApp from our app**, if that can be triggered. On both platforms this is a
  deep-link/intent to another app, not a call we place — cheap to try, no infrastructure, and it
  fails visibly rather than silently if WhatsApp is absent.

Neither puts us in the call path, which is the point.

#### Backups: encrypted, off our server, and readable without us

WhatsApp-style backup, with three properties that are all load-bearing:

- **Never on our server.** "by design we do not save user data even backups." The destination is the
  user's own iCloud, Dropbox, or Google Drive.
- **Encrypted**, obviously.
- **Decryptable without our app.** This is the hard one, and it is the interesting requirement. It
  rules out a proprietary blob whose format lives only in our source: if Channel is gone from the
  App Store in five years, the user must still be able to open their own history. That means a
  **documented, standard container** — an age or OpenPGP file, or AES-GCM with the parameters and
  key derivation written down in `PROTOCOL.md` — plus a published description of the plaintext
  format inside it.

  It also means the key cannot be one only we can regenerate. A user-held passphrase, or a printable
  recovery key, with the derivation specified.

- **We store only the location, locally.** "the app can keep up with where the backup is stored but
  that's all locally stored by device." Credentials for the destination drive belong to the
  platform's own account plumbing, not to us.

#### Why this one is philosophically consistent rather than an exception

Every other decision here removes us from the path — no accounts (`J22`), sealed sender, a server
that cannot read (`J26`), no analytics. A backup we could decrypt would quietly undo all of it: it
would be the one copy of everything, held where we could be compelled to produce it. Backups that we
cannot read, stored where we do not control, is the only version of this feature consistent with the
rest of the product.

---

### J33. Compromise isolation, and the WhatsApp settings we should match. — *Don*

Two questions from 2 Sep 2026, taken together because the answer to the second is in the first.

#### "If someone's phone is compromised, are their contacts compromised too?"

**No — and that is structural, not luck.** `PROTOCOL.md` §6.5's Handshake v1 derives a separate
`sharedSecret` per pair, from a fresh ephemeral X25519 exchange. There is no group key, no shared
secret spanning three parties, nothing an attacker can lift from Bob that helps against Alice↔Carol.

What compromising Bob's device **does** give an attacker, stated plainly:

| Exposed | Why |
|---|---|
| Bob's entire message history | Stored plaintext on his device; no forward secrecy over stored messages |
| The ability to impersonate Bob **going forward** | They hold his identity key |
| Bob's contact list | Which is itself a social graph, and is exactly what sealed sender protects on the wire but not at rest |

Not exposed: any conversation Bob was not part of.

#### The gap is detection, and we already have the mechanism

The attacker who holds Bob's keys can talk to Alice as Bob, and **Alice has no way to notice.**

WhatsApp solves this with "Get notified when your security code changes for a contact" (Don's own
screenshot of Security notifications). We have the primitive already: §6.5's `generation` counter
increments on every re-pair, and `PairingCode.confirmationCode` is the short phrase two people
compare. What is missing is only the surfacing:

- **Notify on key change.** When a contact's identity key or generation changes, say so in the
  conversation, unmissably. This is the single highest-value security feature not yet built.
- **Re-verification.** Let two people re-compare the confirmation phrase at any time, not only at
  first pairing.
- **Forward secrecy over stored history** is the deeper fix and a much larger change (ratcheting).
  Recorded as wanted, not scheduled. Disappearing messages — also on WhatsApp's privacy screen —
  give much of the practical benefit for far less work, and should be considered first.

#### WhatsApp settings worth matching

From Don's screenshots, and his framing: *"I want all the useful things — of course we are not
creating accounts in the traditional sense, or rather the accounts we create are just stored on the
user's own phone, and perhaps in the contact list of people with whom he is paired."*

That last clause is the design in one line: **identity lives on the device and in the contact lists
of people you paired with. Nowhere else.** (`J22`.)

Worth having: Privacy (last seen, profile picture, about — each scoped to *paired contacts* rather
than "everyone", since we have no "everyone"), Security notifications (above), Chats (backup — see
`J32`, wallpaper, history), Appearance, Notifications (`J25`), Storage and data, Starred messages,
Lists, Disappearing messages, Linked devices (`J20`), Blocked contacts, Invite a friend (`J25`).

Not applicable, and worth being explicit about so nobody adds them by reflex: Account Center,
Communities, Broadcast, Status/Updates, Parental controls, anything tying to a phone number.

---

### J34. The Handshake v1 external review, and what verification changed. — *2 Sep 2026*

Ran Grok against `handshake.rs` before shipping, per `CLAUDE.md`'s standing rule for anything
security-critical. Cost under three cents. **Every claim was verified against the code before any of
it was acted on**, which is the part that mattered: of five findings, only two survived at the
severity claimed.

| Finding | Claimed | Verified as |
|---|---|---|
| Completed handshake destroyed by any later packet | High | **CONFIRMED.** `on_packet` clears `output` on any error; `step`'s `_` arm errors for every packet once `Complete`. A replay or a duplicate BLE delivery silently discards a successful pairing. |
| Ephemeral secret not zeroized on error paths | Medium | **CONFIRMED.** There is no `impl Drop for Handshake`. Live X25519 private material survives every pre-`finish` failure. |
| `generation` attacker-inflatable | Medium, framed as contradicting the spec | **Real, but mis-framed.** It does not contradict §6.5 — the spec says exactly this is possible and accepts it. The genuine defect it missed: **no upper bound**, so inflation to `u32::MAX` makes re-pairing that contact permanently impossible. |
| Forged `PairOffer` consumes the state machine | High | **Real, but low.** A nuisance DoS on a face-to-face ceremony where both parties retry. The first flight is unauthenticated by design. |
| Transcript bounds guard skips silently | Low | **Real hygiene.** Not triggerable today; becomes a silent, baffling signature failure the moment a field width changes. |

#### Why this is worth recording rather than just fixing

The review's value was **not** its severity ratings — it got two of five wrong in both directions,
overstating one and understating another. Its value was being a **different reader**: every real
finding is the same shape `CLAUDE.md` already predicts, an outer construct enforcing a rule an inner
one does not. `on_packet` treats "error" as one category and clears state uniformly, without
distinguishing "failed before completing" from "already succeeded, this packet is spurious."

**The discipline is the product, not the tool.** Relaying these unverified would have shipped one
redesign nobody needed (the DoS), missed the unbounded-generation defect entirely, and left the two
real bugs correctly described but buried among three that were not.

---

### J35. "No channel" is a fourth state, not a missing one. — *Don*

From testing the first working conversation on a real Android phone, 2 Sep 2026.

A message sent to yourself never leaves the device. No radio carried it, no server saw it, and
`J27`'s three channels — Bluetooth blue, Wi-Fi green, internet amber — all describe paths that were
not used. Don's read: *"for a message sent to yourself, I guess white is as good as any."*

He is right, and it generalises: **"nothing carried this, it never left the phone" is a real state
that deserves its own mark, not a borrowed one and not an absence.**

#### Why an empty bar is worse than no bar

Self-conversations are local-only by construction — never sealed, never transported, excluded from
every transport's contact list. So `ChannelReachability` correctly reports nothing, and the live
indicator bar renders empty. That is *right*, and it looks *broken*.

An indicator that shows nothing on the first conversation a user opens teaches them the indicators do
not work. They then discount the feature everywhere else, including where it is carrying real
information about whether their message crossed a server. **The cost of a correct-but-blank display
is the credibility of the whole mechanism**, which `J17` and `J25` describe as the one thing on
screen no competitor can show.

#### The rule

- The self case gets an **explicit** state saying the message stays on this phone.
- It does not borrow blue, green or amber. Those three mean specific paths were used.
- `J27`'s constraint still binds: colour is never the only carrier. Whatever mark is chosen must read
  correctly in greyscale, like `B`/`W`/`I` do.

#### The near miss worth recording

This was found in the same photograph as a top-inset clipping bug on the same screen. An occluded
bar and an absent bar look identical from a phone in a hand — so "the lights don't show" had two
plausible causes with entirely different fixes. Establish which before changing anything; that is why
the instruction to the implementer was to determine the cause first rather than to add a bar.

---

### J36. Check marks carry the path in colour; the bar is the key. — *Don*

Refines `J17` and `J27`'s per-message mark, 2 Sep 2026, after seeing the first working conversation.

> "use the check marks like whatsapp for sent and read but the color should reflect the path it took
> to the other user so no letters there as it's too bulky but they can refer to the color at top of
> screen if they want a key"

- **The mark is WhatsApp's check marks**, carrying the familiar sent/delivered/read meaning. `J25`'s
  familiarity test applies: this is a shape people already read without being taught.
- **Its colour is the path** — `J27`'s measured palette, blue Bluetooth, green Wi-Fi, amber internet,
  and `J35`'s fourth state for a message that never left the phone.
- **No letters on the mark.** Per-message letters were too bulky at that size, and a message list is
  where density matters most.
- **The live bar at the top of the conversation is the key.** Its letters `B`/`W`/`I` stay; a reader
  who does not know what amber means looks up once and then knows.

#### This relaxes J27, deliberately, and here is the cost

`J27` says colour must never be the only carrier, because Bluetooth blue and Wi-Fi green sit at
1.35:1 under simulated deuteranopia. **On the check mark, colour now IS the only carrier.** A
colour-blind reader cannot tell from the mark alone which path a given message took.

That is an accepted trade, not an oversight, and it rests on one thing: **the key is always on
screen.** Legend-plus-colour is a legitimate pattern only while the legend is actually visible.

**Therefore this decision depends on `J35`'s companion fix — the bar staying pinned regardless of the
keyboard.** On 2 Sep the bar vanished whenever the IME appeared, which is most of the time a
messenger is in use. If that regresses, this decision silently becomes "colour only, no key," which
`J27` forbids. Whoever changes the bar's visibility owns re-reading this entry.

A cheaper hedge worth considering rather than reintroducing letters: make the *per-message* mark's
path legible on tap or long-press, so the information is recoverable without spending pixels on every
row.

---

### J37. Four letters, no dots: `B` `W` `L` `I`, grey when a path cannot carry. — *Don*

> "Ok, for the lights rather than dots let's do letters in colour. An inactive path is grey and an
> active path is in colour, and we will say B for bluetooth, W for direct wifi, L for LAN, and I for
> internet. They could be different colours or the same, but if the pipe is not available it's going
> to be grey. This way you can in a very compact way show what is available for the message you are
> about to send."

**The dot is gone.** Every previous version drew a dot with a letter under it — two rows of vertical
space to say one thing twice, and the half that was redundant was the half that could actually be
read. `ChannelLightView` now draws a letter and nothing else.

#### LAN gets its own letter, which retires J27's hollow/filled rule

`J27` made direct Wi-Fi and shared LAN both `W`, told apart by a **hollow versus filled dot**, on the
reasoning that they are one channel to a user and differ only in exposure.

That had the user model backwards. "Straight to that phone" and "across the café's router" are not
one channel wearing two hats — they are the two things a person most needs told apart, because the
second means a router that logs and other guests who are peers (`J21` rung 3). Encoding it as the
fill of a 10-point dot put **the most privacy-relevant distinction in the display into its least
legible feature**. `isHollow` is deleted, not merely unused: a spare boolean named after a shape
nothing draws is what the next person gives a second meaning to.

A side effect worth naming, because it is a bug fix hiding inside a redesign: a contact reachable
*both* directly and over a shared LAN used to light **one** `W`, with the more private pathway
winning. The fact that a shared network was also open simply was not on screen. Now both letters
light.

#### Available versus not: colour AND weight, never colour alone

Lit letters are their channel colour and heavy; unlit letters are neutral grey `#ADADAD` (5.24:1 on
garnet, measured) and regular weight. Two signals, one of which is not colour — the same discipline
the letter was introduced under, and it matters more now that the letter is the entire indicator.

The grey is **achromatic on purpose** (there is a test). Beside three saturated letters, an
unsaturated one reads as "off" instantly and keeps reading that way under every colour-blindness
simulation, because chroma survives where hue does not. It is bright enough to stay legible because
`J17` requires the vocabulary to be learnable from the dark state too — an unavailable channel is
still information.

#### `W` and `L` share the Wi-Fi green

Don allowed either ("they could be different colours or the same"). Same, because it is honest — it
is the same radio and the same medium, and what differs is who else is in the path, which is now the
letter's job — and because a fourth hue would have to be measured against garnet *and* re-simulated
against the other three under deuteranopia and protanopia. That exercise already forced violet out of
this palette once. Reusing green introduces no unmeasured colour.

#### Order is the ladder, not the letters as spoken

The bar reads `W B L I` — `J21`'s order, most private first — not the `B W L I` of Don's sentence,
which was naming the letters rather than ordering them. The invariant that pays for this: **the
leftmost lit letter is always the route a message would actually take**, so the bar and
`PathwayTransport` agree without the user being taught a rule. There is a test.

#### What this does not touch

`J36`'s per-message check mark. That entry says the mark carries the path in colour with *no*
letters, and it is still unimplemented — `MessageChannelMark` currently draws a letter on a garnet
chip. This change neither implements nor blocks it. Note that `J36` explicitly depends on the bar
being a visible key, so whoever implements it should read this entry first: the key now has four
letters, not three.

**Verified, not assumed:** 90 tests pass on an iPhone 17 Pro simulator, including new ones pinning
the one-to-one pathway map, the shared W/L colour, the achromatic grey, and its contrast on garnet.

---

### J38. Adding someone from the address book, with no permission and no copy. — *Don*

> "And yes, we'll ask permission to load the user's contacts, but we will not save them anywhere
> other than on the user's own device ... and really, do we have to store a copy of the contacts if
> they're on the device already? Can we display a picker and show contact detail without double
> storing them? If so, that's better."

Yes, and better than the question assumed: **no permission is needed at all.**

`CNContactPickerViewController` runs **out of process**. From Apple's own documentation:

> "The app using contact picker view does not need access to the user's contacts and the user will
> not be prompted for 'grant permission' access. The app has access only to the user's final
> selection."

So the user opens their own address book, in the system's interface, and hands over one card. There
is no permission prompt, no `CNContactStore`, no `NSContactsUsageDescription` — **do not add one**,
its presence is what makes iOS prompt — and no copy of the address book anywhere.

#### The bulk-read method was deleted, not left unimplemented

`PhoneContactSource` used to declare `func importableNames() async -> [String]`. That signature *is*
the whole address book in a return value, and satisfying it honestly required authorization, a store
enumeration, and a usage description. It is gone. An unused method returning every name on the phone
is an invitation, and `J30`'s promise — *"a name the user could have typed is the entire payload"* —
is worth more as a type than as a comment. What survives is a capability flag.

The picker hands back `CNContact`; we read the name via `CNContactFormatter` and **nothing else**. The
closure's parameter is a `String`, not a `CNContact`, so a future feature that wants a phone number
has to widen that signature in the open rather than quietly reading another key.

Multi-select is deliberately off. It would rebuild the bulk import this design exists to avoid, one
screen further along.

#### The two shipped documents changed in this same commit — as J25 and J30 require

That rule was the whole reason this feature sat unbuilt, and it was nearly missed here even so,
because the *permission* claim survived intact. It is the **capability** claim that did not:
`privacy-policy.md` said "The app cannot read your contacts", which stops being strictly true the
moment a picked name arrives. Both documents now state the picker's behaviour precisely.

Play's Data Safety answer stays **Not collected** — nothing derived from the address book leaves the
device, and "collected" in Play's form means transmitted off-device. That reasoning is now written
into `store/data-safety.md` rather than left for the next person to re-derive under time pressure.

#### What is still open

Showing **live contact detail** — photo, phone number, kept in step with the address book — is *not*
what this delivers, and it cannot be done at zero permission. Re-reading a contact later requires
authorization (full or iOS's limited access), and the picker's no-prompt guarantee holds precisely
because the app cannot read anything it was not handed. If Don wants live detail, the honest trade is
to store the contact **identifier only** and re-read through `CNContactStore` — no duplicated data,
but a real permission prompt and another privacy-policy revision. That is his call, not an
implementation detail.

---

### J39. Six languages, device default, OS translation only. — *Don*

Stated 2 Sep 2026.

#### Localisation

- **English, Spanish, Portuguese, Italian, French, German.**
- **No language picker anywhere except Settings.** The app follows the device language; changing it is
  a deliberate act, not a first-run question.
- Measured for comparison: WhatsApp declares **39 localised languages** on its US App Store listing
  (English plus 38) and supports **up to about 60 on Android**. Six is a starting set, not a ceiling —
  the constraint is translation quality and upkeep, not code.
- **Android additionally allows a per-app language** independent of the system setting (Settings →
  App language). Worth matching, and it is the one place a picker outside our own Settings is
  acceptable, because it is the platform's own surface rather than ours.

#### It is localisation, not translation — and that is the whole point

WhatsApp does not translate its interface at runtime. It uses conventional resource-bundle
localisation: the source carries identifiers, not visible strings, and each language ships a table.

    button.title = localizedString("new_message")

    en: new_message = "New message"
    es: new_message = "Nuevo mensaje"
    de: new_message = "Neue Nachricht"

**Scaling from 6 languages to 20 is a translation and QA problem, not an architecture problem** — but
only if the architecture is right from the start. Retrofitting hard-coded strings later is the
expensive version, so the identifiers should go in before the string count grows, even though
shipping the other five languages is not urgent.

What a mature catalogue handles beyond string swapping, none of which is optional at release:
plurals, dates, times, number formats, grammatical variants, **text expansion** (German runs ~30%
longer than English and will break fixed-width controls), font coverage, locale-aware sorting, and
**right-to-left mirroring** for Arabic, Hebrew, Persian and Urdu — where the entire layout flips, not
just the text. None of our six target languages is RTL, so that stays out of scope for now; the layout
should simply not make it impossible later.

**Priority: not now, and do not start the catalogue.** Don, overriding an earlier suggestion of mine
that the identifiers should go in early: *"don't build the table until we're closer to done as we
don't know yet what the final list of english is as we still build features."*

He is right and my advice was wrong in a specific way. A string catalogue extracted while the English
is still churning is a catalogue that is stale every week — every reworded setting, every deleted
paragraph (`J29`, `J31` deleted several tonight alone) becomes a stranded key plus a translation
nobody asked for. The cost of retrofitting is real but it is paid **once**; the cost of maintaining a
catalogue against moving copy is paid **continuously**, and this project's copy is moving fast on
purpose.

**What to keep doing now, which costs nothing:** write strings so they *can* be extracted later.
Concretely — no sentence built by concatenating fragments, no interpolating a clause into the middle
of another clause, no assembling text from words chosen by a `switch`. Those are the patterns that
make extraction genuinely expensive, because they cannot be translated without being rewritten first;
a plain literal in a view is a five-minute change whenever we decide to do it.

So: **literals are fine. Assembled sentences are not.** Build the catalogue when the English settles.

#### Translation of received messages

- Use the **operating system's** translation, on demand or as a default set in Settings.
- **No paid AI translation.** Don's reason, verbatim: *"this app needs to be super cheap to serve so
  there's actually a chance to make $$."* A per-message API cost is a per-message loss on a product
  with no per-message revenue.
- Relevant precedent, measured: WhatsApp shipped **on-device** message translation in Oct 2025 —
  21 languages on iOS, and Android launched with six. So the platform-provided route is what the
  largest messenger chose too, not a compromise we invented.

**The privacy consequence, which is the real reason this is the right call.** A cloud translation API
means shipping the plaintext of a received message to a third party. On a product whose entire claim
is that no one but the two participants can read a message, that would be a contradiction, not a
feature. On-device translation keeps the plaintext on the phone. **If OS translation ever turns out to
be server-backed on some platform, this decision must be revisited before shipping it** — verify per
platform rather than assuming "on-device" from the name.

#### Monetisation — open, with the landscape recorded

Don, thinking aloud: *"do any other messaging apps charge? Maybe the deal is you build value in the
platform with user base and sell the app to someone who then undoes all your privacy policies like
meta did with whatsapp? not sure yet what's the best model."*

What the market actually shows:

| App | Model |
|---|---|
| **Threema** | One-time ~€5. Swiss, no accounts, no phone number. **The closest analogue to this product, and a real business.** |
| **Telegram** | ~$5/mo Premium, plus ads in public channels |
| **Signal** | Nonprofit; donations, seeded by a $50M loan from Brian Acton |
| **Wire, Wickr** | Pivoted to enterprise/government; Wickr acquired by AWS |
| **WhatsApp** | Charged $0.99/yr pre-Meta, then dropped it |

The pattern: privacy-first messengers monetise by **charging up front**, **selling to enterprise**, or
**being a nonprofit**. Advertising and data monetisation are structurally unavailable here — they are
the thing this product is sold against.

**On build-then-sell**, recorded because Don raised it and it deserves a straight answer rather than
enthusiasm: it is a real exit, and it has a specific problem. An acquirer pays for the user base
*because* it wants the data the product promised not to collect. Users would be buying a promise
whose breach is the exit strategy. Threema's route — charge a few euros, stay independent — is the
one that does not require that.

Still open: what to charge, and whether the 14-day-trial-then-subscription Don floated earlier fits a
product with no server cost per user. Nothing here settles `J4`.

---

### J40. Pairing is a rendezvous, and the number is discarded. — *Don*

Don, setting the constraint: *"we need to lower, and not increase the friction inherent in pairing…
at the end of the process we want any contact info to only land on our device and that of the
contacted person and not to live on the server."* And, decisively: *"even if we allow numbers to link
people we still don't have to store it if we use the initial connection to exchange something more
private that is used for day to day comm via the server."*

That is the whole design. A phone number answers exactly one question — *where do I leave this so the
right person finds it?* — and is then dead weight. So it exists on the server for minutes, in a form
the server cannot read, and never again.

**The rendezvous slot.** Alice enters Bob's number; her device generates a six-digit code.
`slot = HKDF(bob_number ‖ code)`. The code never reaches our server, so the slot address is not
derivable from the number space alone. She deposits her identity key and an ephemeral key there,
encrypted under the same derived key; the server sees an opaque address holding an opaque blob. She
sends Bob the code by any channel. His device knows his own number, derives the same slot, decrypts,
and **deposits his reply in the same slot**. Both devices collect and delete. TTL ten minutes.

**Why this matters beyond remote pairing:** the slot *is the return path that does not otherwise
exist*. Pairing today requires both people to scan because the second device has nowhere to put its
answer — the deposit address for a reply derives from contact keys it does not yet hold. Give it a
slot and **one scan completes the ceremony**. The same mechanism serves QR, NFC, a link, and a
number, and later carries device linking (`J41`).

**The hard constraint, which is arithmetic and not an implementation gap:** a first message cannot be
encrypted to someone whose key you do not have. Therefore **the bootstrap mailbox carries key
material only — never content.** The sender's message stays queued on their own device and goes out
over the normal encrypted path once the reply arrives. To the user it reads as *Delivering*, then
delivered.

This is not fastidiousness. The phone number space is roughly 10¹⁰, so anyone — including our own
server — can enumerate every number, compute every bootstrap address, and poll them all. We cannot
prevent enumeration. We make it worthless by guaranteeing there is never anything at those addresses
but a public key and a claim. Every hashed-contact-discovery scheme that skipped this step was broken
by exactly this attack.

**Write control.** An address anyone can compute is an address anyone can deposit into. Unpaired
inbound therefore surfaces the way an unknown SMS does — *"+1‑555‑1234 wants to message you"*,
accept or decline — with rate limiting per address. The spam decision belongs to the person.

**The setting this creates:** *"Let people who have my number reach me."* Off, and the device polls no
bootstrap address at all; you are reachable only by QR, NFC, or link. On, and you get WhatsApp-grade
convenience. Don's framing: *"freedom with options is what i sell and not selling the client."*

Once the handshake lands, the pair never touches the number-derived address again and the number
itself is discarded rather than stored.

#### A QR scan does not prove presence; the compared code does

Don, pressing the NFC question again: *"what do QR codes get us that NFC cannot... this would only be
used to guarantee that a user was face-to-face with the person he's communicating with, raising that
channel to that person to the highest level of authentication."*

**Direct answer: nothing.** If peer-to-peer NFC existed cross-platform it would be *better* — a tap
enforces roughly 4 cm by physics, needs no camera, works in the dark, and has no aiming. QR's entire
advantage is availability: every phone, both platforms, no OS gatekeeping. It wins by walkover, not on
merit. (NFC's unavailability is recorded above.)

**But the premise needs correcting, and it changes what the badge may claim.** A QR scan does *not*
prove face-to-face. The app knows only that a camera saw a code. It cannot distinguish scanning a
person's screen from scanning a screenshot they texted you — the bytes are identical, and a forwarded
image reaches exactly the same state.

**What proves presence is the compared confirmation code.** Two people reading the short phrase aloud
to each other requires a live human channel that a relayed image does not provide. NFC would have
supplied proximity for free; with QR it has to come from the humans.

| Tier | What it actually proves |
|---|---|
| **QR + confirmation code compared aloud** | They were together. **The comparison does the work, not the scan.** |
| **QR alone** | A camera saw a code. Possibly a forwarded photo. |
| **Link or code via another channel** | As strong as that channel; whoever controls it can substitute |
| **Number rendezvous** | Control of that number at that moment |

**Therefore the badge must say "arrived via camera", not "verified in person"**, unless the app
actually required and recorded the code comparison. A badge that infers presence from a scan flatters
the user and is wrong in exactly the case an attacker would construct. Whether comparison is
*required* before a contact reaches the top tier is an open UI question; what is settled is that the
badge may not claim more than the app observed.

**On spoofing the number tier:** the recipient derives the rendezvous slot from *their own* number, so
an attacker must genuinely control that number, not merely spoof caller ID — which is trivial and
would otherwise be the obvious attack. The real exposures are **SIM swap** and VoIP numbers never tied
to a person. Good, not great, and correctly ranked below the in-person tiers.

#### Tone: describe, do not warn

Don: *"we do want to be transparent without making people think we are less secure than the
competition, which, if you mention it too much, it makes it look that way. So you have to be careful
of the wording of that, or just explain it more fully in the documentation and leave it at that."*

**The comparison is better than "no worse than."** Every mainstream messenger routes *all* key
exchange through its own servers — WhatsApp, Signal, all of them — and none offers an in-person
alternative. So rendezvous pairing is exactly as good as their **only** path, and the QR path is
better than anything they offer. Warning heavily about the option that matches the industry norm,
while competitors say nothing at all, would leave users believing the more careful product is the
riskier one. That is a real failure mode of transparency and it has to be designed against.

**A second reason to keep it light:** a warning on the *normal* path trains people to ignore warnings.
If most pairings are remote and each one carries a caution, the caution stops being read — and then
the genuinely weaker cases have nothing left to signal with.

**Therefore:**

| Where | What |
|---|---|
| **The badge** | Does the real work. Comparative, not alarming — "in person is stronger", never "this is unsafe". Persists on the contact instead of flashing once. |
| **At the moment of choosing** | **One neutral sentence.** Describe, do not warn: the key passes through our server briefly, we cannot read it, pairing in person avoids it. No warning icon, no red, no the word "risk". |
| **The documentation** | The full explanation, including the comparison above — where it reads as context rather than defensiveness. |

This satisfies `J29` rather than contradicting it. J29 requires that a benefit never be stated without
its cost; it does not require alarm. A cost stated once, neutrally, and carried thereafter by a
persistent comparative badge is *more* honest than a warning users learn to dismiss.

#### Assurance is asymmetric, and both ends see it

Don: *"a badge on the contact can show level of certainty as to identity at both ends of the chat."*

The badge is a property of **how the key arrived**, and it differs per direction. If you scanned
Lusmar's QR in person but she only tapped a link you texted, you hold strong evidence about her and
she holds weak evidence about you. A single badge for the pair would be false in one direction. So a
contact record carries two values — how I obtained their key, and what I can infer about how they
obtained mine — and shows the weaker. Badge only, no paragraph, per `J29`.

| Method | What it proves | Where it is weak |
|---|---|---|
| **QR in person** | The camera saw the real screen | Nothing meaningful |
| **Link you send** | Only as strong as the channel it travelled | Whoever controls that channel can substitute |
| **Number rendezvous** | Whoever controls that number | Our server mediated; number-holder ≠ person |

**NFC is not among them, and cannot be.** Don asked the right question — *"if close enough to use QR
why not just do nfc?"* — and the answer is that phone-to-phone NFC does not exist on current
platforms. Android's peer-to-peer NFC (Android Beam) was **removed in Android 10** in 2019, and iOS
has never exposed phone-to-phone NFC data exchange; Core NFC reads and writes *tags*, and the Secure
Element work is card emulation for payment, not an app data channel. iPhone↔Android NFC is therefore
impossible and Android↔Android has not worked for years. An earlier draft of this table listed NFC as
a working method; that was wrong, and any store copy implying NFC pairing must be corrected with it.

**This is what QR actually buys us: it is the only proximity channel available between any two
phones**, and it is the strongest one — you are looking at the other person's real screen, so nothing
can be substituted in transit. Needing two scans was never inherent to QR; it was the missing return
path, which the rendezvous slot supplies. QR is also the only sensible way to link a laptop or
desktop (`J41`), which has a screen and no NFC — the same reason WhatsApp uses it there.

One unbuilt possibility, recorded so it is not rediscovered: an Android device using host card
emulation could present a tag an iPhone reads via Core NFC, giving a **one-directional**
Android→iPhone transfer, which a rendezvous slot could complete. Not worth the platform-specific
machinery when QR already does it better in both directions.

**Assurance upgrades, and a failed upgrade is the detection story `J33` lacks.** Two people who paired
by link and later meet can scan once to promote to verified, comparing against a key already held. If
the scanned key does not match the stored one, someone substituted it. `J33` records that compromise
isolation is structural but undetectable; this makes it *visible*, using a gesture people already
perform for another reason.

---

### J41. One identity across devices; several accounts on one device. — *Don*

Don: *"if i used the phone in the day could i continue the chat on the macos app or my ipad? …i'd like
to break that 2 limit thing all in a single app."*

Two problems sharing one primitive — the ceremony in `J40`, pointed at yourself.

**One identity, several devices.** Each device holds its **own** keypair; the account identity key
signs a device certificate for each. Contacts store the account key plus the set of authorised
devices, and senders encrypt to all of them. No private key ever leaves a device, which is what keeps
this honest. Linking is show-QR-on-the-Mac, scan-with-the-phone; history transfers **over LAN, not our
server** — BLE cannot carry a backlog at 20–60 kB/s (`J31`). Revocation is a signed statement that
propagates on next contact, which also gives `J33` a second detection path.

**Several accounts on one device** is then a container problem, not a crypto one: namespace the
keystore, the message store, and the token set by account id. WhatsApp's two-app ceiling is
commercial, not technical — they want businesses on WA Business. We have no such incentive, and
breaking it is a genuine differentiator.

**Correction, recorded because it changes the design.** An earlier draft of this entry claimed
notifications must carry the account. Don: *"our notifications aren't by user, they're by device. And
the notifications are really just wake up calls."* That is right, and it makes multi-account
**simpler**: the wake-up is content-free, so one device has one wake-up stream regardless of how many
accounts it holds. Attribution happens after decrypt, on-device, from material only the device has.
It also means **the number of accounts on a device never leaves it** — a per-account notification
channel would have leaked exactly that. The only remaining constraint is that all accounts' tokens go
in one poll: N accounts cost one wake-up and one round trip, not N.

---

### J42. Sent, delivered, read — and why the tick lags. — *Don*

Don: *"whatsapp shows when sent and when delivered and when read for each message and we should too if
able."*

This composes with `J36`: **the count carries progress, the colour carries the path.** Two dimensions
on one glyph, no letters.

- **Sent** is free — the device handed it to a transport.
- **Delivered over Bluetooth or Wi-Fi is nearly free and instant**, because the acknowledgement is
  inherent in the direct connection. Receipts will feel *better* on direct radio than through the
  server.
- **Delivered via the server is bounded by the recipient's poll interval.** Our server cannot tell the
  sender anything — it does not know who anyone is — so the recipient's device must send a receipt as
  its own envelope. On the two-hour setting, "delivered" can appear two hours after arrival. This is
  not a bug inside the current design; it is the pull tradeoff surfacing in the UI, and it is the
  clearest argument for the opt-in directed delivery in `J43`.
- **Read** is one more receipt, sent when the conversation opens.

Receipts roughly **double envelope volume**, so they ride along with the next poll rather than firing
their own round trip. And read receipts leak when you looked, so they need the WhatsApp switch —
including the fairness rule that turning yours off hides everyone else's from you.

---

### J43. Every language the platforms allow, and one more than WhatsApp. — *Don*

Don: *"If translation is free, we'll support every language possible. At least one more than supported
by WhatsApp."*

**AMENDED 3 Sep 2026, and the amendment matters more than the original.** Don: *"beating whatsapp by 1
is only useful if fast/simple/cheap - not worth major coding for a silly claim. we don't want to be
under and only need to be over if free basically so matching is no shame."*

So the goal is **not to be under**, and to exceed only where exceeding is free. The original wording
committed to 61+ as a target in itself, which is chasing a number rather than a user benefit — and
would have justified real engineering effort for a line in a store listing.

Verified figures, 3 Sep 2026: WhatsApp's interface runs **~60 on Android** and **39 on iOS** (the iOS
listing checked live). Translation is a separate and much smaller feature: **21 languages on iOS**,
6 stable / 19 beta on Android. The two numbers are routinely conflated and must not be.

**Where matching is nearly free, we will exceed anyway** — which is the only reason to expect a higher
number. ML Kit gives 59 on-device translation languages against WhatsApp's best of 21, and UI strings
now cost a Claude run rather than a translation vendor (below). Neither is a reason to *aim* at a
record.

**Two things were both being called "translation", and only one is free.**

*Message translation* — rendering a contact's message into your language — is genuinely free and
on-device via Apple's Translation framework and Google's ML Kit. That is `J39`, unchanged, and it
still costs us nothing to serve, which is the constraint that produced it.

*UI localisation* — our own strings — is not free. Producing the text is near-free by machine; the
**review** is the cost, and this app is a poor candidate for unreviewed output. The words that carry
the product's risk are precisely the ones machine translation degrades quietly: **verified**,
**unverified**, **paired**, **not encrypted**, **direct**, **via server**. A rendering that softens
"not encrypted" into something reassuring is a security defect, not a typo. `J30` makes paired vs.
unpaired a security signal and `J40` makes assurance a per-direction badge; both are carried entirely
by words, in every language.

**Therefore the work is bounded by a critical vocabulary.** Roughly 40–50 security-bearing terms get
genuine human review in every shipped language. Everything else — labels, buttons, settings prose —
rides on machine translation. This converts "sixty languages" from a translation project into a
review project of a few dozen words per language, which is affordable.

**The machine is Claude, not a translation platform.** Don: *"isn't it simpler to just use haiku or
even opus for the one-time work without paying anyone extra?"* Yes. Roughly 400 UI strings across 60
languages, batched one call per language, is a few dollars of Haiku and is re-runnable whenever the
English moves. Transifex and Weblate exist to coordinate *volunteers*, and volunteers are unnecessary
when a first pass takes an afternoon. They become worth revisiting only if we open-source and want
community contributions, at which point their free tier applies anyway.

Three things still need a person, and they are the same three whoever translates:

- **The security vocabulary.** A model will produce something *plausible* for "not encrypted" in
  Portuguese, and plausible is precisely the failure mode.
- **A glossary**, so "paired" is the same word in every string and every release rather than drifting
  into two words for one concept.
- **Layout.** German runs long, and a translation that overflows a button is a bug no model can see.
  That is a screenshot pass, not a text pass.

**A limit to state plainly rather than discover later:** on-device *message* translation covers fewer
languages than the interface will. ML Kit sits near 59; Apple's set is smaller. Some users will have a
fully localised interface and no in-place translation of incoming messages. Acceptable — but it must
not be implied in store listings, which is the kind of overclaim already corrected once in
`store/play-listing.md`.

**Not yet actionable, deliberately.** Don, earlier and still governing: *"don't build the table until
we're closer to done as we don't know yet what the final list of english is as we still build
features."* This entry settles the *scope* so it need not be reopened; it does not authorise building
the catalogue. What it does require now is that the code stay extractable — no assembled sentences,
no concatenated fragments, every user-visible string reaching the screen through one accessor — so
that the catalogue is a mechanical extraction when the English finally stops moving.

---

### J44. Push is a user's choice, and the trade is stated. — *Don*

Don: *"if a user says he wants push he should be able to get it as long as he understands what he's
trading - the same he trades for every other message platform on the planet, and for those users the
destination has to be exposed, we still don't save his messages (absent a court order) but he gains
convenience and battery and it saves us bandwidth but net and server cpu effort."*

And, correcting a wrong objection of mine: *"not sure we need the source of the message to notify the
recipient as the receiver gets to figure that out on arrival."* That is right. The wake-up needs only
to reach the destination device; the recipient learns who it is from after collecting and decrypting.
`J41` already establishes that wake-ups are content-free and device-scoped, and that is sufficient
here.

**What it costs, exactly.** Today `deposit.mts` never touches the registry (`J10`) and wake-up is an
unconditional heartbeat that does not know which mailboxes hold mail, so the server holds nothing
joining a mailbox to a device. With push enabled it holds **this device ↔ these mailboxes**. That
mapping is durable, and it is compellable. Combined with data from the other end it is a social graph.

**What it does not cost.** We still cannot read anything, still do not learn the sender, and retention
is unchanged: deleted on confirmed receipt, thirty-day cap that nobody can raise (`J6`).

**Mechanism.** Destination tokens rotate, so a `token → device` map goes stale in ten minutes. The
device therefore precomputes a rolling window -- a day of upcoming tokens -- and registers them in one
call, re-registering daily. Registering per epoch would be its own poll and would defeat the battery
saving that is the point.

**Global, not per contact -- and it is a yes/no, not a preference with exceptions.** Don worked this
out directly: *"the question is if anything is gained by suppressing server pushes for a given user.
maybe it doesn't hide anything useful in which case notification is either a yes or a no."* It does
not, for three reasons that stack:

1. **It cannot be expressed.** The wake fires because something landed at the recipient's destination
   token. Sealed sender means the server never learns who deposited it, so there is no per-sender
   thing on the server for a rule to attach to.
2. **It would hide nothing if it could be.** Suppressing one contact's push does not stop their
   messages arriving -- the device still polls on schedule and still collects them. The only
   difference is latency. No observer learns less, because the server never held per-sender
   information to begin with.
3. **It would be privacy-negative.** Asking the server to treat one contact differently means telling
   the server that contact exists as a category. It would *create* a distinction on the server in
   order to hide something the server already cannot see. Strictly worse than not having the feature.

Per-contact granularity therefore belongs entirely to **notification**, which happens on the device
after decrypt and can know anything the device knows.

#### The per-contact control that does matter, and its threat model

The privacy case for per-contact settings is not about the network at all. It is about the lock
screen. A **silent contact** -- no banner, no sound, no preview, the message simply present when the
app is opened -- protects someone whose risk is a person physically near them: an abusive partner, a
colleague, anyone who can see the phone face-up on a table. That is a different threat model from
network surveillance and it is the one where per-contact granularity genuinely protects a person.

The vocabulary is already half-built: `J25` gives per-conversation notification sounds, and the
settings screen already warns that a distinctive tone tells anyone in the room which contact just
messaged you.

**These sit on top of the OS controls, not instead of them.** Both platforms already own preview
visibility and lock-screen behaviour at the system level. Don: *"there are os controls about previews
and maybe in app controls about previews and lock screens too in some cases so we should probably
have in app notify preferences just like whats app has."* So the in-app layer is a WhatsApp-shaped
set of per-conversation preferences -- mute, sound, whether a preview appears -- and it must not
pretend to override a system setting it cannot see. `J25`'s familiarity test applies: this is a
screen users have already learned somewhere else.

**Framing in the UI.** Not "private" versus "fast". Polling is not free of exposure either -- a device
that checks in every fifteen minutes forever hands the server a continuous presence signal tied to an
IP. Push trades that continuous signal for one durable identifier. Different shapes, and the setting
must say so rather than implying pull is simply purer (`J29`: the explanation must be short, and it
must be true).

---

### J45. What can be compelled, and why push is the hinge. — *Don*

Don, reasoning about legal compulsion: *"a court can ask for what you hold and that's only messages
sent and not delivered - they might can make you start saving messages but you can only comply for
messages directed at a particular user."*

**Not legal advice and not a legal position.** This entry records the *technical* facts that decide
what is producible, because those are an engineering matter and they are what the legal argument
would be about. The legal questions Don raises -- bulk versus particularised demands, and whether a
provider can be compelled to change its code and violate its own terms without notice -- are
genuinely unsettled and want actual counsel before any of it reaches marketing copy.

**What exists to be produced today is nearly nothing.** Undelivered envelopes only: ciphertext this
server cannot read, at a rotating token linked to no person. Delivered messages are already gone
(deleted on confirmed receipt, `A6`), and `J6`'s thirty-day cap is hard-coded rather than
configurable, which is what makes "we do not have it" a property of the system instead of a promise.

**Targeting normally fails for want of a subject.** A demand for everything belonging to one person
is one this service cannot satisfy, because nothing joins a person to a token. That is a far stronger
position than a policy of refusing, and it is the reason `A7` and `J10` are worth their cost.

#### Push is the hinge, and the UI must say so in those words

`J44`'s `wakeMap` is **the only structure in the system that joins a device to specific mailboxes.**
For a user who has opted into push, "produce this device's traffic" becomes an answerable question.
That is not a side effect of push; it is what push *is*.

Two consequences for the build:

- The setting must not describe this as "we store an identifier." It must say the thing that is
  actually true: **this is what makes you findable.** `J29` requires it be short; it must also be the
  real cost rather than a softened one.
- `subscribe.mts`'s row expiry is a privacy control, not housekeeping. It bounds how much a
  *prospective* order sweeps up, which is the exposure that actually matters (see below). It should
  stay as short as the battery saving tolerates.

#### Prospective orders are the real exposure, and E2E is what blunts them

Nothing can produce what was deleted, but a provider can be ordered to stop deleting. The structural
answer is that compelled retention still yields ciphertext, because keys never reach this server
(`A7`). "Preserve everything from now on" does not become "read everything."

**The genuine frontier is compelled code change** -- not "hand over data" but "ship a build that
weakens the client." End-to-end encryption has no technical answer to that today. The partial answers
are reproducible builds and binary transparency, so that a targeted malicious build is *detectable*
rather than merely forbidden. Not built, not scheduled, and recorded here because it is the only
defence in this entry that does not depend on winning an argument in court.

Don's summary of the position, which is accurate: *"the exposure only produces meta data if the court
doesn't force you to ship spyware to users."* Metadata is the ceiling, and the client staying honest
is the condition.

**The platforms are not equal here, and the difference is structural.**

| | Can a user verify their binary? |
|---|---|
| **Android** | **Yes.** Build from source, sideload, compare. A targeted malicious update is detectable by anyone who bothers -- and the *possibility* of detection is the deterrent, since a targeted attack that gets discovered is worse than useless to whoever ordered it. |
| **iOS** | **No.** Apple re-signs binaries and the store is the only channel, so nothing lets a user confirm that what they received is what we uploaded. No build discipline on our side fixes this. |

**What follows now, while it is still cheap:** deterministic builds -- pinned toolchains, no embedded
timestamps, sorted inputs -- and keeping the client source publishable. Both are nearly free today and
both become expensive to retrofit once build scripts assume otherwise. Neither commits us to making
the claim; they are what make the claim possible later.

#### Why the app must not checksum itself, recorded because it gets re-proposed

Don: *"you could crc check big chunks of your pertinent code and publish a value in the app store
description that needs to match what the app itself calculates -- but the court could order you to lie
about that too."* The last clause is right, and there are two further reasons it fails:

- **The attacker controls both sides.** A compelled build ships a compelled checksum routine. An app
  verifying itself is a suspect confirming his own alibi.
- **On iOS the value would not even be stable.** Apple re-signs the binary and has historically applied
  FairPlay encryption to the executable, so the bytes on the device are not the bytes we built. A
  self-check would fail honestly and constantly for every user -- the same property that makes iOS
  unverifiable at all.
- **A store description is mutable, unsigned and has no public history**, so there is nothing for a
  user to audit against.

**The insight underneath it is sound, though, and worth keeping.** It notices that the attack changes
shape: it stops being "produce what you hold" and becomes "state something false." That is the
warrant-canary argument -- compelling silence is thought to be easier than compelling a lie, which runs
into compelled-speech objections. Untested and thin, but it asks the right question: *what does the
order have to make us do?*

**Which is why the answer is a record we cannot quietly retract**, not a number we assert. Independent
rebuilders cannot all be compelled, and an append-only transparency log (Certificate Transparency's
shape) does not prevent a bad binary being published -- it prevents it being **un**published or hidden.
A targeted attack becomes globally visible, which is what makes it worthless to whoever ordered it.

**The standing rule this produces:** keep the set of things that can be compelled as close to empty as
the product allows, and be explicit at every point where it is not. Each such point should be a user's
informed choice (`J44`) rather than a default.

---

### J46. The sender carries the wake id, and what that still cannot fix. — *Don*

Don, following `J44` to its consequence: *"when the destination has to be known there's not much point
in rotating tokens as you can't send without knowing the id... it could be encoded at rest (only the
destination not the content of message) with the server having the key perhaps."*

**Two corrections, then a better design, then the part none of it fixes.**

**Encryption at rest with a server-held key is not a defence against compulsion.** A demand goes to
the operator, who holds the key. It genuinely protects against a storage breach, a subpoena aimed at
the host rather than at us, or a misconfigured bucket — all worth having, none of them the threat in
`J45`.

**Rotation is degraded for push users, not pointless.** It still hides the linkage from anyone who
sees traffic but not our store, and it still bounds the window. What it stops doing is hiding that
user from *us*.

#### The better design: nothing stored

The sender already knows who they are writing to, so **the sender supplies the wake id** and this
server keeps no map. A recipient hands paired contacts an opaque `notifyId` at pairing; a sender
attaches it to the deposit. The only durable row is `notifyId -> pushToken`, which has to exist to
call APNs at all and joins a device to **no mailbox**. Token and notify id meet only in memory, for
one request, and nothing logs.

Against the `wakeMap` table this replaces: no stored join, so nothing to produce retrospectively;
rotation keeps its meaning; and going dark is issuing a fresh `notifyId` and deleting the
registration, rather than purging rows and hoping. This is why `subscribe.mts` was deleted rather
than kept.

Costs, inside a relationship already chosen: every paired contact learns your `notifyId`, so a
hostile one could wake your device to drain battery — **rate limiting per id is required, not
optional** — and a contact learns you are reachable by push.

#### What no server-side cleverness fixes

`J10` raised two objections to targeted push and the design above answers only the first. The second
is irreducible:

> What targeted push would have given them is **delivery timing**: when a user receives messages, how
> often, at what hours.

**Any** targeted push tells Apple and Google when a message arrived, because that is when it fires.
Conversation rhythm — who is active at 2am, how often someone is contacted — goes to a third party,
and no storage scheme on our side changes it. The unconditional heartbeat exists precisely to
decorrelate this, firing identically whether mail is waiting or not, and it remains the default.

**Therefore the setting states three things, not two** (`J44`, `J45`, `J29`):

1. Our server can link your device to your mailboxes — **avoided** by the design above.
2. Apple and Google learn when you receive messages — **not avoidable**, and the one most people never
   consider.
3. In exchange: instant delivery, better battery, less cost to run.

---

### J47. Relay-assisted discovery, and the flood it invites. — *Don*

Don, 3 Sep 2026: *"add the phone as relay code too... so this includes the discovery parts both for the
destination and the relay to be able to assist with it so if you don't see the destination directly you
see if any relays are available and ask them to look for it too"* — and *"relays might even look for
other relays if available to do the same search."*

This is **multi-hop discovery**, which is a different problem from the multi-hop *carrying* `J1` and
`J14` defer. Carrying moves an envelope you already know where to send. Discovery is asking *"can
anyone see this person?"*, and the asking is the part with the privacy and cost problems.

#### The property that makes it viable at all

A naive version — "have you seen Bob?" — hands every relay a social graph. Ours does not have to,
because `PROTOCOL.md` §4.4 already gives every contact a **rotating presence token**: an opaque 16-byte
value derived from the pairwise routing secret and the current epoch, meaningless to anyone who does
not hold that secret, and unlinkable across epochs.

So the query is *"do you see this token?"* A relay checks it against the tokens it has heard in beacons
and answers yes or no. It learns that somebody is looking for **an** unnamed correspondent, which is
strictly less than it learns by carrying an envelope for them — a thing `J14` already contemplates.

**What it does add, stated plainly:** `THREAT-WALKTHROUGH.md` already concedes a relay can link a query
to a following envelope by timing. Multi-hop discovery multiplies the number of relays holding that
timing observation, and the second hop learns the query without ever seeing the requester. That is a
real widening of the metadata surface, and the honest framing is that relay mode trades metadata
exposure for reach — the same shape as `J44`'s push trade, and it should be presented the same way.

#### The flood, which is the actual engineering problem

*"Relays might even look for other relays"* is, unbounded, a broadcast storm: every node asks every
neighbour, who asks every neighbour, forever, in a topology with cycles. Three bounds are required and
none is optional:

| Bound | Why |
|---|---|
| **Hop limit** | A query carries a small TTL, decremented per hop, dropped at zero. `J21`'s ladder already implies two hops is the useful range; beyond that latency exceeds what a person will wait. |
| **Duplicate suppression** | A query id, remembered briefly, so a cycle does not carry the same query round forever. The same mechanism `PROTOCOL.md` uses for envelope duplicate suppression. |
| **Rate limit per neighbour** | Otherwise one hostile node turns every relay into an amplifier against the rest of the mesh. |

**The hop limit cannot be authenticated**, exactly as `PROTOCOL.md` already records for envelopes:
relays share no key with the requester, so a malicious relay can reset the TTL. That makes rate
limiting the real defence and the hop limit merely the cooperative case.

#### Reciprocity, inherited

`J14` governs: relaying is binary, local and free — the encounter handshake carries a bit, and a node
that does not relay is not relayed for. Discovery inherits it unchanged. **It cannot become
per-peer accounting**, for the reason J14 gives: sealed sender means a relay cannot tell whose envelope
it holds, and opportunistic encounters give no repeated game.

#### Cost, which decides whether anyone leaves it on

Answering queries means listening more, and listening is the expensive radio state. A relay that
answers everything is a phone with a flat battery by lunchtime, and a feature people switch off is
worth nothing. The battery budget belongs in the design **before** the protocol work, not after — it is
the constraint most likely to kill the feature, and the one easiest to discover too late.

#### Status

**Design only. Not built, and deliberately not started tonight**: it needs new packet types, changes in
`mesh-core-rs`, and both platforms, and half of that landing unsupervised is worse than none of it. It
also sits behind the `Gate` in `ROADMAP.md` — a relay mesh built on a pairing whose secret is derived
from public keys would be a larger version of the same mistake.

---

### J48. Direct Wi-Fi cannot cross platforms; on iOS it is nearly free. — *Don*

Don, 3 Sep 2026: *"WiFi to WiFi direct isn't present in app."* Correct, and worse than unwired:
`DeliveryPathway.directWiFi` is **rung 0**, the most-preferred path on the whole ladder, and **no
transport for it exists on either platform**. The `W` light in the channel bar has never been able to
light. Same shape as the Bluetooth defect (`J-BLE`, fixed 3 Sep): a pathway named in the enum, shown in
the UI, implemented nowhere.

#### The hard constraint: it cannot work between an iPhone and an Android phone

| | Peer-to-peer Wi-Fi API | Interoperates with the other? |
|---|---|---|
| **iOS** | No Wi-Fi Direct API at all. Apple's peer-to-peer Wi-Fi is **AWDL**, reached through MultipeerConnectivity or `NWParameters.includePeerToPeer`. | **No** — AWDL is Apple-proprietary |
| **Android** | `WifiP2pManager`, i.e. Wi-Fi Direct proper | **No** |

So *Wi-Fi Direct the protocol* can only ever work **same-platform**. This is the NFC finding again
(`J40`) — a capability that reads as universal and is not.

**But "direct Wi-Fi between an iPhone and an Android" is NOT impossible, and an earlier draft of this
entry said it was.** Don: *"how does wifi connect to a printer or washing machine work during set up...
many devices allow direct wifi by manifesting a temporary hotspot."* Exactly — appliance setup almost
never uses Wi-Fi Direct. It uses **SoftAP provisioning**: the device raises its own temporary access
point, the phone joins it as an ordinary Wi-Fi *client*, they talk over that network, and the hotspot
goes away. No peer-to-peer protocol anywhere.

That pattern crosses platforms, asymmetrically:

| Role | Android | iOS |
|---|---|---|
| **Raise a hotspot** | Yes — `WifiManager.startLocalOnlyHotspot`, API 26+ | **No.** Personal Hotspot is user-initiated with no API |
| **Join one** | Yes | Yes — `NEHotspotConfiguration`, with a prompt |

So **Android hosts and the iPhone joins**, never the reverse, and the SSID and password could ride in
the pairing code already on screen.

**The costs make it a deliberate mode, not a background path.** Joining a hotspot drops the iPhone off
its normal Wi-Fi, so it loses internet for the duration; both sides prompt; it is asymmetric; and below
Android 13 it still wants location permission. Worth building as an explicit *"send this now, quickly"*
action — not as a rung the router picks silently, which would take a user's internet away without
asking.

**It matters more than NFC did**, because of `J46`'s exposure argument: direct Wi-Fi is the *more
private* rung — no third party in the path — and shared LAN is the one that hands timing and MAC
addresses to whoever runs the café network. The rung users would most want is the one that cannot
cross platforms.

#### On iOS it is nearly free, and that changes the order

`LANTransport` already uses Network.framework — `NWListener` and `NWBrowser` over Bonjour, not the
deprecated `NSNetService`. Setting **`NWParameters.includePeerToPeer = true`** makes the same code
discover and connect over AWDL with no infrastructure network present. That is a small change against
existing, working transport code, and it gives iPhone↔iPhone direct Wi-Fi — which is precisely Don and
Lusmar's pair.

Android's `WifiP2pManager` is a separate and much larger piece: its own discovery model, group
negotiation, and a group owner. Not comparable in cost.

**But it is not a flag flip, and the trap is worth naming.** Setting
`includePeerToPeer` on the *existing* `LANTransport` would make one transport carry both rung 0 and
rung 2, with no way to tell which a given connection used — so every direct-Wi-Fi delivery would be
reported as `sharedLAN`. That is precisely the distinction `J46` says matters most: direct has no third
party in the path, shared LAN hands timing and MAC addresses to whoever runs the network. Misreporting
it would corrupt the one signal the check-mark colour exists to carry.

Attribution has to come from the interface actually used — AWDL appears as its own interface (`awdl0`
on iOS) rather than as `.wifi` — or from two separately-parameterised listeners. Either is real work.
**Do not enable the flag without solving attribution first.**

**Therefore:** do iOS peer-to-peer first because it is cheap, and treat Android Wi-Fi Direct as its own
project. **Do not let the channel bar imply the `W` light is available cross-platform** — under `J37`
the letter must go grey when a path cannot carry, and for a mixed pair it can never carry.

---

### J49. Voice and video: the nearby case fits, the distant case does not. — *Don*

Don asked how WhatsApp's calling works, in the context of *"maybe someday enable voip"* (`J32`).

**How WhatsApp does it.** Signalling through their servers (offer/answer plus the call key, derived
from the existing Signal-protocol session), then media peer-to-peer over UDP using ICE/STUN to traverse
NAT, falling back to **TURN relays** when hole-punching fails — common on mobile behind carrier-grade
NAT. Media is end-to-end encrypted with SRTP; a relay forwards ciphertext and sees only addresses and
timing. Peer-to-peer leaks each party's IP to the other, which is why they added *"Protect IP address
in calls"* to force relaying, at a latency cost. Group calls cannot be peer-to-peer and use an
SFU-style server under a group key.

**What that means here.**

- **Nearby calls fit this architecture better than WhatsApp's.** Two phones on the same network, or on
  a direct link, can carry a call with **no server at all** — not even for signalling, which WhatsApp
  always routes through Meta.
- **Distant calls fit it badly, and the reason is money.** TURN relays *media bandwidth*. Everything
  else this service does is a few hundred bytes at a time with a hard 32 KB envelope cap (`J31`);
  relaying a call is a continuous stream, and it is the one workload that would make the server
  genuinely expensive. That is in direct conflict with the constraint the whole product is built
  around.
- **Signalling already exists.** `PairingRendezvous` is a signalling channel — an address two devices
  derive without a third party naming it.
- **The blocker is ringing a sleeping phone.** A call must arrive *now*, which means push, which means
  the durable identifier `J44` deliberately makes optional. **A call feature effectively requires push
  to be on.** Decide that deliberately rather than discovering it during implementation.

**Status: not planned.** Recorded so the cost is understood before anyone starts. The honest shape if
it is ever built is *nearby calls only, no relay* — which is a real product ("talk for free with no
network at all") rather than a worse WhatsApp.

---

### J50. Browse your own contacts; keep none of them. — *Don*

Don, 3 Sep 2026: *"I already said you needed to allow a user to browse his own contacts but **no saving
or uploading of contacts is allowed**."*

**This reverses `J38` in one specific way and leaves the rest standing.** J38 chose the system contact
picker precisely so no permission was needed and no address book was read, and
`Model/PhoneContactSource.swift` carried a prohibition against restoring a bulk read: *"Do not add a
fetch-all method back to this protocol. If some future feature seems to need one, it needs a
privacy-policy change and an owner decision first, in that order."*

Both now exist, **and the order was honoured**: `store/privacy-policy.md` §2 and §9 and
`store/data-safety.md` were rewritten in the same commit as the code, not after it. That sequencing is
not ceremony — earlier the same day the live privacy policy claimed NFC pairing the app could not do,
and the lesson was that a published document contradicting the binary is the failure mode to design
against, not a paperwork step.

#### What "no saving" is enforced by

| Claim | What makes it true |
|---|---|
| Not persisted | `PhoneContactBrowser` holds `state` in memory; nothing is written to storage |
| Gone when the screen closes | `clear()`, called on selection and on Done, with a test that fails if `state` survives it |
| Not uploaded, not hashed | No code path takes it anywhere; `J38`'s "no contact matching, not hashed" still holds |
| Minimal in the first place | Three keys fetched — given name, family name, organisation. Not photographs, postal addresses or birthdays |

What survives a browse is exactly what survived the picker before it: **the display name of the one
person tapped**, saved as if typed.

#### The Data Safety answer does not change, and that is worth stating

Play asks about data **leaving the device**. Nothing does, so *Contacts: not collected* remains correct.
What was wrong was the *explanation*, which said no permission is requested. `store/data-safety.md`
now flags that if Play asks about **access** as distinct from collection, the answer is yes.

#### Both routes stay

The picker needs no permission and returns one person; browsing needs Contacts access and shows the
list. A user who declines loses the second and keeps everything else — and the denied state says so,
naming a remedy and an alternative rather than dead-ending, which is the defect the pairing code-entry
screen was fixed for the same day.

**Android parity is owed** and is not done: `READ_CONTACTS` and the equivalent screen. The permission
model differs enough that it is its own piece of work.

---

### J51. The check cadence governs the foreground rate too, so Check Now has a job. — *Don*

Don, 3 Sep 2026: *"we said when the app is open that we're polling for messages once a minute yet we
have a check now button. check now button has no purpose if you're checking once a minute. the only
place check now is a factor is if you've set the config to poll some time longer than a minute."*

**He is right, and this corrects `J18`.** J18's table marked the foreground layer *"No — the user is
waiting"* and fixed it at 90 seconds. The consequence was a button that could never matter: no choice a
user could make left a gap worth closing by hand. That is the same defect as a Bluetooth toggle over an
unwired transport or a Default sound that plays nothing — **a control that cannot affect anything** —
and this is the third one found in a day.

#### The options, as Don specified them

Every minute · about every 15 minutes · about every 30 minutes · about every hour · only when I tap
Check Now. Most frequent first; never last.

#### Two layers, one setting, and only one of them can honour the fast end

| Layer | Rate |
|---|---|
| **App open** | Exactly what was chosen. The app owns its own clock here, which is why these labels can be exact where the others hedge. |
| **App backgrounded** | The same choice, **floored at fifteen minutes** |
| **Check Now** | Immediately, always |

**The floor is not a compromise, it is the truth.** No phone wakes for a background check every
minute, so asking the server for that would spend wake-ups the device cannot act on. Don had already
said as much: *"when the phone is asleep every 15 min is max possible and that isn't guaranteed of
course."* `J13`'s rule — never print a number the system cannot keep — applied to what we ask the
server for, not just to what we show.

`.onDemand` now means **no timer in either layer**, which is what makes Check Now the only way messages
arrive on that setting, and therefore a control that matters.

#### One migration detail worth keeping

The raw values `onDemand`, `slow` and `normal` are unchanged, because they are what is already stored
on shipped builds and the hand-written decoder falls back to the default on an unknown string —
renaming them would silently reset the setting a user is most likely to have chosen deliberately.
`.slow` moves from two hours to one: Don's list has no two-hour rung, and stretching it only widens the
gap Check Now exists to close.

**Android parity is owed.**

---

### J53. Bluetooth is the signalling channel that starts Wi-Fi. — *Don*

Don, 3 Sep 2026: *"figure out how you'd trigger/detect cross platform knowing that one or both might
be (likely will be) using wifi for internet when the exchange needs to start to send a message
direct."*

That is the real engineering problem in `J48`, and it has a known answer: **out-of-band handover**.
AirDrop does exactly this — BLE advertises and negotiates, AWDL carries the payload — and Android's
NFC-to-Wi-Fi-Direct handover is the same shape.

**BLE is the only channel that survives both phones being busy on infrastructure Wi-Fi**, which is the
normal case. So it is what negotiates everything else.

#### CORRECTION — and it changes what SoftAP is for

Don, immediately: *"but ble isn't available at 100 feet and wifi is."* Correct, and it undoes the
framing above. If BLE negotiates the handover then Wi-Fi is only reachable **within BLE range**, which
throws away the entire range advantage that motivated it.

**The range lives in the same-platform paths, and those need no Bluetooth.** AWDL discovers over
Bonjour at full Wi-Fi range; Android's Wi-Fi Aware (API 26+) and Wi-Fi Direct do their own discovery
likewise. Neither involves BLE at all.

**Cross-platform has no shared discovery at Wi-Fi range** — Wi-Fi Aware is Android-only, AWDL is
Apple-only. So a mixed pair can only find each other by BLE (~30 ft), by already being on the same
LAN, or through the server; and the last two mean they can already talk.

**Therefore cross-platform SoftAP is not a discovery mechanism. It is a transfer upgrade** between
devices already in contact, and its value is:

- **Throughput** when they are in BLE range — roughly 100× for a photo or a long message
- **Privacy** when they are on the same LAN — it gets them off a shared network, which is exactly
  `J46`'s distinction: a café LAN hands timing and MAC addresses to whoever runs it

#### SECOND CORRECTION — signalling and payload are separate concerns

Don again: *"what about internet to wake the cross transfer while payload doesn't go on internet -
wifi lan could wake the cross transfer even more securely than internet when it's available and ble if
available (close) but wifi lan if farther and internet via server if no wifi lan connects the two."*

That is the decomposition both earlier versions missed. **The signalling channel and the payload
channel are independent.** Negotiation is a few hundred bytes — "let us go direct, here is the SSID" —
and can travel over anything. The payload still never touches the internet.

So the range is not lost after all. **LAN signalling works at full Wi-Fi distance**, which means a
mixed pair a hundred feet apart in the same building can negotiate a SoftAP handover.

| Signal over | Reach | What is disclosed |
|---|---|---|
| **BLE** | ~30 ft | Nothing — no infrastructure involved |
| **Wi-Fi LAN** | ~100+ ft | Whoever runs that network sees a brief exchange |
| **Server** | Anywhere | A deposit at a rotating token. Not who, not what (`J46`) |

Ordered by disclosure, and the ladder is chosen per attempt: nearest and quietest first.

**The LAN case is a privacy upgrade, not merely a speed one.** Two devices already on the same network
*could* simply send over it — but `J46` records that a shared LAN hands timing and MAC addresses to
whoever runs it. Negotiating over the LAN and then moving the payload to a direct link means the
network sees a short exchange and then nothing at all.

**Wi-Fi's range advantage is therefore available cross-platform after all** — but only where some
channel already reaches, and the reach of the *whole* mechanism is the reach of its signalling. Any UI
must promise the latter, never the former.

#### Reachable is not near, and the failure lands on the iPhone

Don: *"we won't know if the two are in direct wifi range until the signal arrives and is acted upon so
keep that in mind."* Correct, and it is the operational constraint the design has to be built around.

| Signalled over | What it proves about distance |
|---|---|
| **BLE** | Within ~30 ft. A Wi-Fi handover will almost certainly succeed. |
| **LAN** | Same network — one room, or a whole building. |
| **Server** | **Nothing.** Could be different continents. |

**BLE presence is the only reliable predictor.** LAN and server signalling can *initiate* a handover
but cannot forecast it, so those are attempts rather than plans.

**And the cost of a failed attempt falls on the iPhone**, which drops its Wi-Fi, fails to find a
hotspot that was never in range, and reconnects — having lost internet for nothing. Three rules follow,
which are really one rule (*fail cheaply*):

1. **Android raises the hotspot and confirms it is up BEFORE the iPhone leaves its network.** Never
   the reverse. A failure on the Android side must cost the iPhone nothing.
2. **Short join timeout, and rejoin the original network immediately on failure.** The window with no
   internet is bounded in seconds by us, not left to iOS's own retry behaviour.
3. **Fall back silently to the path that was already working.** A failed upgrade should look like a
   slightly slower send, never an error — the user asked for speed, not for a status report.

#### Personal Hotspot is mutually exclusive with this

Don: documentation must reflect that *"features such as personal wifi will not be compatible with
direct wifi."* Correct, on both sides and for different reasons:

- **An iPhone actively sharing Personal Hotspot cannot join another Wi-Fi network.** A SoftAP handover
  would fail, or would knock whoever is using that hotspot off it.
- **An Android already hosting a hotspot cannot generally raise a second local-only one**, so it
  cannot be the SoftAP host.
- **An iPhone *using* someone else's hotspot loses that connection** the moment it joins ours.

So the app must **detect the conflict rather than discover it as a failure** — offering a direct send
that will visibly break the user's tethering is worse than not offering it. This belongs in the
capability flags of `J14`'s encounter handshake alongside the rest: *can I host*, *can I join*, and
*am I currently unable to do either*, asserted per attempt rather than assumed from platform.

**The UI may only offer what is known.** With BLE presence, "send directly" is a confident offer. With
only the server, it is a gamble that costs connectivity to take, and presenting the two identically
would be promising reach we cannot verify.

#### Same platform first, because nobody loses connectivity

| Pair | Mechanism | Costs the user |
|---|---|---|
| **iOS ↔ iOS** | AWDL via `NWParameters.includePeerToPeer` — **runs alongside infrastructure Wi-Fi** | Nothing |
| **Android ↔ Android** | Wi-Fi Direct on its own virtual interface | Usually nothing; older devices may drop Wi-Fi |
| **Mixed** | SoftAP: Android hosts, iPhone joins | **The iPhone's internet, for the duration** |

iOS↔iOS is also the cheapest to build — it reuses `LANTransport`'s existing Network.framework code —
so it goes first. It is also Don and Lusmar's pair.

#### The mixed-platform sequence

1. Both devices are on their own Wi-Fi. **BLE is up regardless** and carries the negotiation.
2. They agree to hand over. Android raises a local-only hotspot.
3. **The SSID and passphrase travel over BLE encrypted under the existing pairwise secret**, so
   proximity alone does not let a stranger join. This is the step that makes the whole thing safe, and
   it is only possible because the two are already paired.
4. iPhone joins via `NEHotspotConfiguration`, transfers, rejoins its own network.

**Therefore mixed-platform direct Wi-Fi is an explicit user action — "send this now, quickly" — and
never a silent choice by the path chooser.** Taking someone's internet away without asking, to save
time on a transfer they did not say was urgent, is not a trade the app may make for them (`J11`: no
bundles, no modes chosen on the user's behalf).

#### Detection belongs in the encounter handshake

`J14` already puts a "do I relay" bit in the encounter exchange. Capability flags go in the same
place — AWDL, Wi-Fi Direct, SoftAP-host, SoftAP-join — so the chooser is mechanical rather than
guessing from a platform string. **A capability is something a peer asserts about itself**, never
something inferred: an iPhone that has been denied local-network permission cannot do AWDL, and no
amount of knowing it is an iPhone reveals that.

**Status: designed, not built.** Behind the pairing Gate, and behind `J48`'s pathway-attribution
problem — a transport that reports direct Wi-Fi as `sharedLAN` would corrupt the exact distinction
`J46` says matters most.

## J54 — Android pairing reaches parity, and comparing the two exposed a shared defect

**Date:** 3 Sep 2026

Android now runs the same Handshake v1 rendezvous as iOS: `RendezvousPairingHandshake`
(scanner), `RendezvousPairingListener` (displayer), a `PairingOutcome` carrying keys plus a
rotating code, and a Verify dialog that runs the exchange *on appear* and shows the twelve
digits that exchange produced. `UnverifiedPairingHandshake` stays in the tree as the
documented weak case and for tests; nothing in the app builds it any more.

The confirmation code has **one implementation, in Rust**, reached through the FFI on iOS
and JNI on Android. That is the point of doing it there: the 64-byte-secret and
nonce-length bugs both came from two hand-written implementations of one rule drifting
apart, and this class of divergence is now impossible by construction rather than by
discipline.

**Writing the second implementation is what found the bug in the first.** Three defects,
all one mistake — treating a single bad envelope as the end of the attempt:

1. *iOS listener, forged first flight.* `PAIRING-REVIEW.md` finding 3 was fixed for
   envelopes that could not be **decoded**, but a decodable one whose signature failed set
   `state = .failed` and ended the attempt. The slot is unauthenticated — only its contents
   are — so anyone who had seen the QR could park 48 shaped bytes in it and kill every
   pairing that device tried. A packet that does not verify is simply not the one we are
   waiting for.
2. *iOS listener, `awaitConfirm` taking `.first`.* Flight 3 lands on the same slot flight 1
   used, and our delete of flight 1 is best-effort. On a failed delete the next poll handed
   flight 1 straight back and the exchange died on its own opening packet.
3. *Android listener, no deadline.* A scanner who walked away mid-exchange left the
   displaying device holding a Rust handle and showing "waiting" for ever.

Only the stage **after** an exchange has begun is bounded (60s). Waiting for a first flight
has no deadline, because a code on screen that nobody has scanned yet is this screen's
ordinary state, not a stall.

Legacy 32-byte codes are refused on both platforms with a message naming the cause. A
fallback would silently be the old, weaker pairing — the exact thing being replaced.
**Consequence: 1.3 will not pair with this build.**

## J55 — Link previews are fetched by the SENDER, never the receiver

**Date:** 3 Sep 2026 · **Status:** decided, not yet built

A link preview looks like a rendering feature and is actually a disclosure feature. If the
receiving phone fetches the URL to draw the card, then:

- the site learns the receiver's IP address, roughly their location, and their user agent;
- it learns this at the moment the message is *read*, which leaks read timing;
- anyone who can post a link can therefore probe whether a given contact opened a message, and
  from where, by watching their own server logs;
- and a link sent by an attacker becomes a beacon that fires inside a messenger whose whole claim
  is that it does not phone home.

That is a de-anonymisation primitive delivered by a convenience feature, so:

**Normative. The sending device fetches the preview, and the title, description and thumbnail
travel inside the sealed envelope as ordinary content. The receiving device never requests the
URL.** The receiver renders bytes it already has. Tapping the link is of course a deliberate act
and opens the browser as normal — the rule is about what happens *without* being asked.

Consequences accepted:

- **The sender leaks instead**, to the site they are already choosing to link to. That is a
  disclosure they opted into by pasting the URL; the receiver's was not.
- **A preview can lie.** A malicious sender can attach any title and image to any link. So the
  URL itself must always be displayed, legibly, and the preview must never be the only thing that
  says where the link goes. This is the same trade every messenger makes; the difference is that
  ours is a deliberate choice recorded here rather than a side effect.
- **Previews cost bytes** (a thumbnail is 20–50 KB against a text message's ~1 KB), which is one
  more reason `MAX_ENVELOPE_BYTES` has to rise — see `ATTACHMENTS-AND-COST.md`.
- **It must be switchable off**, per the same principle as everything else: a user who wants no
  outbound fetch at all should get exactly that, and the setting should say what it protects.

## J56 — Reaching someone by phone number without ever storing one

**Date:** 3 Sep 2026 · **Status:** designed, blocked on 10DLC registration

The requirement: start a conversation from a phone number, without a directory, without an
account, and without the app learning who is who.

Three cases, and the third is the one that needs care.

1. **They have the app and published the number as a discovery method.** Then a lookup is
   legitimate, because they asked to be findable. What is stored server-side is not the number but
   a salted hash of it, and what comes back is a pairing payload, not an identity.
2. **They have the app and did not publish it.** Nothing happens. Silence is the correct answer,
   and the UI must not distinguish this case from case 3 — "no result" and "not published" have to
   look identical, or the absence of a result becomes a way to enumerate who is on the service.
3. **We cannot tell whether they have the app**, which is the ordinary case. Then the only honest
   move is to *ask them*, over the channel we were given: an SMS invite.

**The invite carries a code, not a number.** The link resolves to a one-time pairing payload that
this sender minted; it does not encode the recipient's number, and our server never records the
number it was sent to. Telnyx sees the number, because Telnyx is the carrier and that is
unavoidable — the honest disclosure is that *sending an SMS invite tells our SMS provider the
number you invited*, and that this is the one moment Channel touches a phone number at all. A user
who does not want that has the QR and the code, which touch nothing.

**Do not let anyone claim a number they do not control.** Standing constraint, restated because it
is the failure that matters: registering an app against someone else's number would let an
attacker sit where that person's invites arrive. Any "publish my number" flow must prove control
of the number at the moment of publishing, and must re-prove it rather than trusting a stored
claim.

**Blocked on:** a Telnyx 10DLC brand and campaign registration, which takes weeks and is per-sender.
TrialsForMe's existing Telnyx setup **must not be reused** — it is a separate business that may be
sold, and its messaging reputation is not ours to spend.

## J57 — Polling's value is that it is constant-rate, not that it hides an identifier

**Date:** 3 Sep 2026 · **Status:** decided

*Prompted by: "so if someone knows we receive something but doesn't know who is the sender what
does that gain? Maybe someone subscribes to a group news feed and gets a lot of messages while
another gets 2 or 3 secret agent messages per day in tehran or tel aviv."*

The question is right and the example answers it, though not in the obvious direction.

### What knowing "you received something" actually gains

**Correlation, which needs neither content nor sender.** Two observed streams can be linked by
timing alone: if a wake-up reaches device B two seconds after device A transmits, repeatedly, the
pair is established statistically. This is the classic attack on mix networks and it works fine
against sealed content. Nobody has to break anything.

**Classification by volume, which is the point of the example.** A hundred wake-ups a day looks
like a feed subscriber and is boring. Three a day at 02:00, 14:00 and 22:00 is a schedule, and a
schedule is tradecraft. **Low volume is what makes a user interesting, not high volume** — the
inversion matters, because the intuition that "I hardly use it, so there's nothing to see" is
exactly backwards.

**Correlation with external events.** Receive-times that cluster around a raid, an arrest or a
publication tie a device to an event without anyone reading a word.

The former director of the NSA and CIA, Michael Hayden, put the general case in 2014: *"We kill
people based on metadata."* Whatever one makes of the remark, it is not privacy advocates who
first claimed the pattern is the intelligence.

### So what does polling actually buy

**Not anonymity — uniformity.** A poll every fifteen minutes looks the same whether it collects
nothing or five messages. It decouples *network-observable events* from *message events*, which is
a different and stronger property than hiding an identifier.

Push does the opposite by construction: every wake-up **is** a message-arrival event, visible to
Apple or Google, to the network operator carrying the connection, and to anyone correlating links.
In the example: with push, the feed subscriber and the person receiving three messages are
trivially distinguishable. With polling, they are not. That is the whole answer.

**Our polling is close to perfect cover, and the remaining gap is narrower than I first wrote.**
Correcting myself: I claimed response padding had not shipped. It has, as `J12` — `_shared.mts`
rounds every response up to a shared bucket, and the top bucket genuinely covers the worst case
(64 envelopes at `MAX_ENVELOPE_BYTES`, base64-inflated, is ~2.8 MB against a 4 MB top bucket).

**But bucketed is not constant, and the difference is exactly the signal in the example.** The
buckets are `[512, 2048, 8192, 32768, …]`. An empty mailbox lands in 512; one ordinary text message
lands in 2048; a busy one lands higher. So an observer counting bytes cannot learn *how many*
messages arrived, but can still learn *roughly how much* — no mail, a little, a lot. That is a
coarse version of precisely the feed-subscriber-versus-three-messages distinction this entry exists
to defeat.

**Pinning every collect response to a single size closes it, and it is affordable:** at 2,880 polls
per user per month, a constant 8 KB costs about $0.003/user/month on Netlify (+30%), a constant
32 KB about $0.012 (roughly doubling a penny), and on an egress-free store it costs nothing at all.
**Do this when the cheap-poll change lands**, since both touch the same response path.

### The consequence that decides the default

**If only at-risk users poll, polling becomes the signal that identifies them.** A privacy mode
used exclusively by people who need one is a beacon: the anonymity set collapses to exactly the
population it was meant to protect. This is why the transport cannot be chosen by threat model
alone.

**Therefore polling stays the default for everyone, and push is opt-in.** The majority who neither
know nor care about any of this provide the cover traffic that makes the minority's polling
unremarkable. That the cost analysis independently finds polling to be about a penny per user per
month (`UNIT-ECONOMICS.md`) means this costs us nothing to hold.

**Honest scope.** For almost every user this protects against nothing they will ever face, and
saying otherwise would be the kind of claim this project exists not to make. It matters for a small
number of people, in a small number of places, some of the time — and the design question is only
ever whether those people are made identifiable by using the thing that protects them.

## J58 — Attachments are many ordinary messages, not one big one

**Date:** 3 Sep 2026 · **Status:** core built, platforms pending

**Photos and files, no video.** Video is deliberately absent from `MediaType` rather than reserved:
it needs transcoding, codec negotiation, thumbnails and a player, and each is a place to leak
metadata or ship a crash. It gets its own entry when it is built.

### The limits, and where each number comes from

| Constant | Value | Why this number |
|---|---|---|
| `ATTACHMENT_CHUNK_BYTES` | 16,000 | Largest chunk whose encoded Payload still fits the 16,384 padding bucket. 16,384 itself would spill into the next bucket and double every transfer. |
| `MAX_ATTACHMENT_BYTES` | 1,048,576 | A generous ceiling for a photo, not a target. Compression aims far lower. |
| `MAX_ATTACHMENT_CHUNKS` | 66 | Derived from the two above, never written independently, so they cannot drift apart. |
| `MAX_ATTACHMENTS_IN_FLIGHT` | 8 | Bounds the assembler, mirroring `MAX_REASSEMBLY_BUFFERS`. |
| `MAX_ENVELOPE_BYTES` | **32,768, unchanged** | The point of chunking is that this never has to move. |

**Compression target for photos: about 300 KB** at 2048 px on the long edge. The 1 MB ceiling is a
bound, not an aspiration — a message that hits it should be re-encoded, not sent.

### Why chunking rather than a bigger envelope

Three properties a single large envelope cannot have:

1. **Uniformity.** Every chunk is exactly the same size, so the wire shows a count of identical
   objects and nothing about the picture. A single envelope would land in a padding bucket, and the
   bucket would leak roughly how big the photo was.
2. **Resume.** A transfer that dies at chunk 19 of 32 refetches one chunk. Over the connections
   this app is for, that is the difference between "eventually" and "never".
3. **Nothing new to get wrong.** Every existing bound, transport and padding rule applies unchanged.

**`DECISIONS.md` C7's warning applies verbatim, one layer up.** A sender declaring a modest chunk
size while `total_bytes` describes something enormous is exactly the attack fragmentation invited,
so `decode` validates `total_bytes` **before** anything sized by it is allocated, requires the
declared count and declared size to describe the same object, and requires every chunk but the last
to be full — otherwise a sender pads out a slot count with near-empty chunks.

## J59 — Compression is off for attachment chunks, and barely earns its keep for text

**Date:** 3 Sep 2026 · **Status:** attachment half decided and built; text half open

*Don: "What do we do about compression of text messages, or does that save anything when we are
padding packets?"*

Measured rather than argued. Realistic non-repeating prose, bytes actually sent:

| Message | Bucket without compression | Actually sent | Saved |
|---|---|---|---|
| 10–160 B | 256 | 256 | **0** |
| 200–300 B | 1024 | 256 | **768** |
| 500–620 B | 1024 | 1024 | **0** |

**So compression pays only in a narrow band — roughly 165 to 350 bytes — and nothing outside it.**
Below that everything lands in the 256 bucket regardless; above it, prose does not compress hard
enough to drop a bucket. The earlier intuition that padding erases the benefit is *mostly* right,
with a real exception in the middle.

**What the saving is worth.** 768 bytes at $0.13/GB is about **one ten-millionth of a cent**. Over
the internet, compression is economically meaningless. Over BLE at a couple of KB/s it is closer to
half a second on a subset of messages, which is the only argument for keeping it.

**What it costs.** The bucket transition is itself a content signal: a 256-byte payload now means
*either* a short message *or* a longer one that compressed well, and "this compressed well" is
information about content. That is the CRIME family, and it is why §5.1's forward rule already
forbids sharing a compression context between authored and received text. The rule holds today only
because there is no quoting or forwarding feature; both are ordinary things to want.

**Decided now, for attachments:** chunks are **never** compressed (`payload.rs::encode`). The
uniformity property in `J58` is otherwise true only by luck — JPEG and PNG bodies happen not to
compress — and it would fail selectively on the one chunk holding a flat region, leaking exactly
when the image is unusual. A test asserts a flat chunk and a noisy chunk encode to the same size.

**Open, for text:** whether to keep it at all. The honest summary is that it buys a rounding error
of bandwidth, half a second on BLE, and a standing obligation to remember CRIME every time a
quoting-shaped feature is designed. **Recommend removing it when quoting or forwarding is
specified**, since that is the moment the obligation becomes load-bearing rather than theoretical.

## J60 — Receipts identify a message by its envelope, and carry their own timestamp

**Date:** 3 Sep 2026

`DeliveryState.delivered` has always been documented as *"a delivery receipt for outbound"* and
nothing ever sent one, so an outbound message stopped at `.sent` for ever. Don remembered the
intent and was surprised it was not implemented; it was not.

### Which message a receipt is about

The two devices share no message id. A `Message.id` is minted locally, so the sender's id and the
receiver's id for one message are different UUIDs, neither of which the other has seen.

**The identifier is therefore derived from the envelope, which both sides hold**: `SHA-256` of the
sealed bytes, truncated to 16. Stable, unique per message (the envelope contains a random packet
id), and it required **no wire format change at all** — the alternative was threading a new field
through the payload, the FFI and the JNI to carry something the data already determines.

A retry re-seals and so produces a different wire id. Correct rather than a flaw: the message the
receiver actually holds is the one to acknowledge.

### The timestamp travels inside the receipt

*Don: "might have to link with push or more maybe delayed notifying if based on pull."*

Exactly the right worry. Over polling a receipt can arrive fifteen minutes after the event, so a
sender stamping the moment the *receipt* arrived would report a time that is wrong by up to a poll
interval, every time. The receipt carries the moment it happened on the recipient's device.

**This is also why the UI is a list of times rather than a live tick.** *Don: "can show date like
as does if you pull the message to left to show sent received read times."* A tick that turns blue
the instant somebody reads needs a live connection; a table of Sent / Delivered / Read is
historical, so a late receipt is still completely correct. That reframing is what makes receipts
work on a pull architecture at all.

Clocks are not synchronised and the sheet does not pretend otherwise. A receipt claiming a time
more than a day ahead is clamped rather than dropped — the event did happen, and rendering next
year as fact would be worse than rendering approximately now.

### Read receipts: on by default, and reciprocal

On by default because that is what people expect, with a switch for those who care — *"it just
works for those who don't care."* **Reciprocal, as WhatsApp's is:** turning it off also stops you
seeing others'. Anything else lets a user take the information without giving it.

Worth being clear-eyed that this is genuine metadata — a read receipt tells the sender when you
picked up your phone, which is the class of signal `J57` argues about. The difference is that the
person learning it is the one you chose to talk to, not a third party.

Only genuinely-unread messages are acknowledged, once. Receipts are fire-and-forget: an
undeliverable acknowledgement is not worth retrying, and queueing them would give an unreachable
contact an unbounded backlog of acknowledgements nobody will read.

A forged receipt can only set a timestamp on a message the forger already proved they received,
since it must name a wire id — so it is not a vector, merely noise.

## J61 — The screen lock accepts either factor, and fails open rather than trapping anyone

**Date:** 3 Sep 2026

*Don: "either pin or face if at least one is set up by user as can't do without one or other and
should always have a backup so no one locked out incorrectly."*

**Either factor satisfies it.** iOS uses `.deviceOwnerAuthentication` and Android
`BIOMETRIC_WEAK or DEVICE_CREDENTIAL`, so a fingerprint, a face or the device PIN all work and the
PIN is always the fallback. Requiring biometrics alone would fail on a wet finger, a mask, or three
bad attempts, and there is nothing to fall back to.

**The toggle is offered only when the device can satisfy it.** A switch that locks the app and then
cannot unlock it is worse than no switch. iOS had this; Android did not until Don's message
prompted the check — its toggle was live on a phone with no lock screen at all.

**And it fails open when the device can no longer authenticate**, which is the case that matters
and the one nothing else defends:

> A user enables the lock while a passcode exists, then removes the passcode in Settings — which
> neither knows nor cares that this app depended on it. From that moment no prompt can ever
> succeed. With no account, no server-side copy and no recovery path of any kind, refusing to open
> would shut somebody out of their own messages permanently.

Refusing also buys nothing in that state. **The gate assumes the device has a lock; with no lock,
whoever holds the phone is already past every other door.** So opening is both the safe behaviour
and the honest one.

Tested on iOS with an `LAContext` that reports no factor available, because this is a property that
must not regress quietly — the failure it prevents is invisible until it happens to somebody, and
then it is unrecoverable.

**Scope, restated because it is easy to overstate.** This gates the *interface*, not the data. The
conversation file is already protected by platform file encryption while the device is locked. This
stops somebody holding your **unlocked** phone from reading your messages. It is not a second layer
of cryptography and the settings copy says so.

## J62 — A contact records how it was paired, and a weak pairing can be upgraded later

**Date:** 3 Sep 2026

*Don: "the records of course will need to have room to store things like when and how paired so we
can show the level of security / certainty as to the id of the contact with whom we communicate and
someone paired low security could get a higher level by using a better verification method one day
when closer for example."*

### The obstacle, and why it forces a second number

The rotating confirmation code (`J52`) is the strong check: it binds the handshake's **ephemeral**
shared secret, so matching digits prove the exchange itself was not interposed. But
`handshake::Output`'s `Drop` scrubs that secret the instant pairing finishes — deliberately —
and keeping it around so it could be re-checked later would trade forward secrecy for a
convenience. **So the rotating code can only ever be compared during pairing.** Don's upgrade path
is impossible with it.

**Hence a safety number**, derived from what both devices keep for ever: the two identity public
keys, sorted, domain-separated, eight groups of five digits. Signal's design, and for the same
reason.

The two are not redundant and neither replaces the other:

| | Proves | Comparable |
|---|---|---|
| Confirmation code (`J52`) | the key agreement was clean | only during pairing |
| Safety number (`J62`) | the keys match | at any time, for ever |

An interposer who substituted a key at pairing time cannot make two safety numbers agree, which is
exactly what makes a later comparison worth doing.

### What is stored, and what is derived

Stored on the contact: the **method** (scanned in person, code read in person, code sent to you,
split code, not paired), whether the two people have **confirmed numbers**, when that happened, and
the cryptographic **assurance** the pairing itself provided.

`IdentityConfidence` is **derived, never stored** — verified / not verified / weak / none. Storing
it would let the badge disagree with the facts underneath it, which is precisely the bug a summary
field invites.

Four named levels rather than a percentage: a number would imply a precision nobody has and invite
comparing two contacts as though the difference were measurable.

**Method is fixed at pairing and never changes. Confirmation can be raised at any time, and the
lateness is the feature** — two people who paired by sending a code over WhatsApp can meet months
later, compare eight groups of digits, and raise their own confidence without re-pairing and
without touching a key.

`PairingAssurance` became `Codable` with an explicit string raw value, because an enum whose cases
are persisted must not change meaning when somebody reorders the declarations.

### J63. Ten phones in a room, all pairing at once: addressing, not identification. — *Don's stress test*

Don: *"there could be a dozen phones in the room with bt... if 10 phones are there and they are
all showing a qr code right now and all looking for a bt signal to complete the pairing - this is
the stress test."* And: *"does the phone know how it appears on bt?"*

**Answer to the second question first: no, and that is deliberate on both platforms.**
`BluetoothAdapter.getAddress()` has returned `02:00:00:00:00:00` to ordinary Android apps since
Android 6; iOS never exposes the local BLE address, and what a peer sees is a per-central rotating
UUID. Both platforms randomise BLE addresses precisely so an app cannot use them as a stable
identity. **A scheme that identifies the target device by its Bluetooth appearance cannot be
built**, on either platform, at any privilege level we will ever have.

**It also is not needed, because we address a slot rather than a device.** The rendezvous token is

    SHA256("channel/pair-rendezvous-v1" ‖ displayedPublicKey ‖ attemptNonce ‖ direction)

Every displaying phone puts a **fresh random 16-byte nonce** in its own QR. So each of the ten
phones is listening on an address derived from *its own* key and *its own* nonce. The scanner is
the only other party that knows both, because it read them off the glass.

The nine wrong phones therefore:

1. **Are not listening on that address.** Guessing it means guessing a 16-byte nonce.
2. **Could not read it if they were.** The offer is sealed to the displayed identity key; only the
   holder of the matching private key opens it.

Two independent barriers, either of which alone would be sufficient. The offer can be shouted
into the room over BLE, LAN, or the relay and it remains legible to exactly one phone.

**Why Don's proof-of-possession idea is weaker than what is already here.** He proposed asking the
Bluetooth peer to repeat the code just scanned, to prove it was the phone that showed the QR.
Repeating the code proves only that the device *saw* it — which is also true of anyone who
photographed it over a shoulder. Sealing to the public key proves possession of the **private
key**, which is the property we actually want, and it is what the rendezvous already does.

**The one residual case, and why the confirmation digits exist.** If somebody photographs B's QR
and scans it themselves, they can compute B's slot and deposit an offer too. B then sees two
offers. That is exactly what the confirmation code (`J52`) is for: B compares digits with the
person standing in front of them and accepts one. The attempt nonce is per-attempt, so accepting
one discards the rest.

**CORRECTION, same evening.** The paragraph that stood here said Android never called its
listener and that wiring it was "the whole fix". That was wrong. It rested on grepping
`PairingScreen.kt` for `RendezvousPairingListener`, which finds nothing because the screen takes
the listener from `AppContainer` rather than constructing one:

    val listener = remember { if (AppContainer.isInitialized) AppContainer.pairingListener else null }
    DisposableEffect(displayPayload) { ...listener?.start(scope, nonce)...; onDispose { listener?.stop() } }

**One-scan pairing is implemented on both platforms.** The observed double scan and the
messages-arrive-nowhere symptom are therefore still unexplained; the leading candidate remains the
version mismatch (a pre-build-12 Android emits a 32-byte legacy code that iOS correctly refuses).
Re-test on current builds before looking further.

### J64. The displaying phone beacons a per-attempt value derived from its own QR. — *Don*

Don: *"how does the phone showing the qr code tell the scanner what his bt signal will be? could
just broadcast part of the qr code key or a hash of it that would need to match the qr just
scanned perhaps."*

**Right, and necessary.** Today `BleMessageTransport` advertises the service UUID and nothing
else, and iOS advertises `[CBAdvertisementDataServiceUUIDsKey: [MeshUUID.service]]` — so every
Channel phone in the room presents an identical advertisement. A scanner has no way to tell ten
of them apart, which is the gap `J63` leaves open.

#### The amendment: a hash including the nonce, never any part of the key

Don offered "part of the qr code key **or** a hash of it". It must be the hash, and the hash must
include the per-attempt nonce:

    beacon = SHA256("channel/pair-beacon-v1" ‖ displayedPublicKey ‖ attemptNonce)[0..16]

**Broadcasting any part of the identity key would be a permanent tracking beacon.** The key never
rotates, so a fragment of it is a stable identifier that anyone who has ever seen that person's
code — or simply harvested fragments in a public place — could use to recognise their phone
forever after. That is the identical mistake `PAIRING-REVIEW.md` finding 2 caught in the
rendezvous address, where deriving from the key alone gave one address per person for life.
Including the nonce makes the beacon per-attempt: it exists for the seconds a QR is on screen and
is never seen again.

Domain-separated from `channel/pair-rendezvous-v1` so a beacon can never be replayed as a slot
address, or the reverse.

#### It is advertised only while the code is on screen

Start on entering the QR screen, stop on leaving. A beacon that outlives the ceremony is exactly
the tracking identifier the paragraph above refuses.

#### Platform note: iOS cannot put arbitrary service DATA in an advertisement

`CBPeripheralManager.startAdvertising` accepts only `CBAdvertisementDataLocalNameKey` and
`CBAdvertisementDataServiceUUIDsKey`. Arbitrary service data — which Android allows via
`AdvertiseData.setServiceData` — is simply not available.

**So the beacon travels as a synthesised 128-bit service UUID**, `CBUUID(data: beacon16)`, which
both platforms can advertise and scan for. Sixteen bytes is exactly a UUID, so the beacon is the
UUID rather than being carried inside one. The scanner, having just read the QR, computes the same
UUID and scans for that specific one instead of the generic service. Foreground only, which is
where pairing always happens.

#### The beacon is discovery, not security — and that is what makes it cheap

A forged or colliding beacon costs nothing. The offer that follows is sealed to the displayed
identity key and addressed to the rendezvous slot (`J63`), so a phone that answers a beacon it
should not have cannot open what it receives, and learns only that somebody nearby is pairing.

That is why sixteen bytes with no signature is sufficient here, and why this must never be
described as authentication. **It shortens a search. It does not decide anything.**

### J65. Profiles, the self card, and the compact status letters. — *Don*

Don: *"for each user profile on the phone the user should populate whatever he wants on his own
contact card that would be shared to paired contacts... if wants to be anonymous to some receivers
he just creates an anon profile or pseudonym profile for that purpose... tell me how the user goes
about creating a new profile or gets rid of old ones or sees the list."*

Nothing of this exists today: there is one identity from `IdentityStore` and one store directory,
`filesDir/ChannelMessenger`. This is foundational, not a feature bolted on the side.

#### 1. A profile is an identity plus a store, fully separated

    ChannelMessenger/profiles/<profileId>/ ── identity keypair
                                          ├── contacts
                                          └── conversations

**Profiles MUST NOT share the identity keypair, and this is the single most important line here.**
The safety number (`J62`) is derived from identity keys. If two profiles shared one key, both
would show the *same* safety number to everybody — so anyone paired with the pseudonym and with
the real name could compare two numbers, see they match, and know the two are one person. That
defeats the entire purpose in the one case Don created the feature for. Separate keypair per
profile, or the feature is a lie.

Cap: **ten** (Don, earlier: *"just to make us less an abuse / spam app"*).

#### 2. The self card, and what may never be on it

Each profile has exactly one self contact — iOS's "me" card — which the user fills in with
whatever they want a paired contact to see. On pairing it travels inside the sealed handshake, so
it is encrypted to that peer and the relay never sees it in clear.

**Shareable:** display name, photo or avatar, and any optional line the user types.
**Never shareable, ever:** identity key material beyond the public key already exchanged, pairing
provenance (`PairingProvenance` — how and when you paired, and how sure we are), delivery policy,
rendezvous tokens, or any internal record kept *about* the contact. Those are notes we hold on
them; they are not the person's business card. A single `shareable` projection on the model, and
the wire format carries that type only — never the storage type — so a future field cannot leak
by being added in the wrong place.

**Updates do not apply silently.** If a contact changes their card, the receiving device shows
*"Lusmar changed their display name"* and asks. A messenger that silently accepts a rename lets
somebody become "Mum" or "Chase Fraud Dept" on your screen without you noticing, which is the
cheapest social-engineering attack there is.

#### 3. Which profile you are pairing as — the highest-risk moment in the app

**The pairing sheet must show, prominently, the avatar and name of the profile being paired.**
Pairing with the wrong profile selected hands your real identity to somebody you intended to be
anonymous with, they keep your key, and *there is no taking it back*. Every other mistake in this
app is recoverable; this one is not. It gets a persistent header on the pairing screen, not a
subtle tint.

#### 4. The profile picker

**Top right of every top-level screen: the current profile's avatar or photo.** Tap it and a sheet
comes up — the same gesture and the same corner on both platforms.

    ┌─────────────────────────────┐
    │ Profiles                    │
    │  ● Don            2 unread  │  ← current, checked
    │  ○ Weekend                  │
    │  ○ (no name)                │  ← a pseudonym profile
    │ ─────────────────────────── │
    │  + New profile              │
    │  ⚙ Manage profiles          │
    └─────────────────────────────┘

- **Switching is one tap** and immediate. No confirmation: it is not destructive and it is the
  thing people will do most.
- **New profile** asks only for a name and an optional photo, and the name may be left empty —
  an unnamed profile is a legitimate choice, not an incomplete form.
- **Manage profiles** is where renaming, changing the photo, and deleting live. Deletion is
  behind a typed confirmation and says the true thing: it destroys that profile's identity key
  and every conversation in it, nobody can restore it, and contacts paired with that profile will
  never be able to reach it again.

**Settings stays on the bottom bar** and is scoped to the selected profile, with the profile's
name in the header so it is never ambiguous which one is being edited.

#### 5. Status letters: letters only, in colour, on every screen

Don: *"the light bars should always be visible to show connective status on all pages and should
be compact - not lights and letters - can be just letters in color and big enough to actually see
them but can be close together."*

Four letters for the four rungs of `J21`, in ladder order, tight:

    W  B  L  I     Wi-Fi Direct · Bluetooth · Local network · Internet

- **Colour identifies the rung**, using the pinned values (`ChannelBluetooth`, `ChannelWifi`,
  `ChannelInternet` — measured under `J27`, not to be adjusted by eye).
- **Opacity identifies availability**: lit when that path can reach this contact now, dimmed when
  it cannot. Colour is therefore never the only channel carrying meaning, which keeps it legible
  to a colour-blind reader — the letter says which, the brightness says whether.
- The dot is removed. It was a second glyph saying what the letter already said.
- **W will be dark for everyone until direct Wi-Fi is built** (it is rank 1 in `J21` and not yet
  implemented). A permanently dark letter is honest, but if it tests as confusing, hide the rung
  rather than fake it.

Identical placement, order, colours and letters on both platforms.

### J66. Neither app should ask for contacts permission at all. — *Don found it*

Don, on the iOS Phone-contacts screen: *"it shows a bunch of cute icons above and a choice to
select or share but there is no escape from the screen and it's not clear who exactly you are
sharing with or to."* And then the question that settles it: *"says allow access to contact? You
already had access to the contact or you could not have selected it right? What exactly are you
asking here?"*

**He is right, and the prompt is not asking what it appears to ask.** It is not asking to read the
contact he just chose. It is asking him to grant Channel **standing access to that contact's
record**. It appears because the app declares `NSContactsUsageDescription` and calls
`CNContactStore`, and on iOS 18+ a contacts request is answered with the limited-access picker —
a permission grant wearing a picker's clothes.

#### The screen is doing two contradictory things

| Button | Mechanism | Permission needed |
|---|---|---|
| **Choose One from Contacts** | `CNContactPickerViewController` | **none** — runs out of process, returns one name |
| **Browse My Contacts** | `CNContactStore` (`PhoneContactBrowser.swift`) | full contacts access |

The first is the design this project already argues for everywhere: the app never sees the address
book, the user picks, one name comes back. The second is the only reason Channel asks for contacts
permission at all — and it produces the confusing screen, the inescapable sheet, and a usage string
promising we do not keep what we just asked permission to take.

#### Decision: delete the browse path on both platforms

**iOS:** remove "Browse My Contacts", remove `PhoneContactBrowser.swift`'s `CNContactStore` use,
and **remove `NSContactsUsageDescription` from Info.plist entirely.** One button remains: *Choose
from Contacts*.

**Android has the same out-of-process picker and we are not using it.**
`Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI)` returns a single contact under
a one-shot URI grant, with **no `READ_CONTACTS` permission**. Today `PhoneContactBrowser.kt`
queries `CONTENT_URI` directly and the manifest therefore requests `READ_CONTACTS`. Replace it
with `ACTION_PICK` and **delete the permission from the manifest.**

#### Why this is the right trade and not merely a simplification

The browse list offers one thing the picker does not: search across the whole book inside our own
UI. That is worth very little — the system picker has its own search, and people know it — and it
is bought with the single most sensitive permission the app requests. Removing it means:

- **The strongest possible version of the claim.** Not "we read your contacts and do not keep
  them", which asks for trust, but *"Channel cannot read your contacts"* — which the operating
  system enforces and anyone can verify from the permission list.
- **The store data-safety forms get simpler and more honest** on both platforms.
- **The confusing screen disappears**, because there is no permission to ask for.

Don, 3 Sep 2026, set the constraint originally: *"no saving or uploading of contacts is allowed."*
This goes further in the same direction — not "we do not save them" but "we never had them."

### J67. If you need that much text, you are probably lying. — *Don*

Don, looking at the contacts screen: *"Also falls in those too much text on all of the screens. If
you need that much text, you're probably lying."*

**Measured, because the number argues better than the opinion does: 143 user-facing strings over
60 characters, totalling 12,928 characters — roughly 2,200 words, or nine paperback pages, inside
a messaging app.** The worst single string is 223 characters. Six of the ten worst are on one
screen, Settings.

#### He is right, and the reason is worth stating precisely

Most of that prose was written to be *honest* — every clause is true, and several were added after
he asked for a cost to be disclosed. But length reads as defensiveness. A paragraph explaining why
a setting is safe implies somebody expected an argument, and a reader who was not worried before
starts wondering what they missed. **The copy was defending the product to a reader who had not
accused it of anything.**

WhatsApp does not explain itself on every screen. It says what a control does and moves on. That
is not because it has less to hide; it is because confidence reads as trustworthy and
over-explanation does not.

#### The rule

1. **A control gets one line saying what it does.** Aim for 90 characters; treat 120 as the wall.
2. **If it has a real cost, one more line.** Not a paragraph, and never a reassurance.
3. **Everything else moves behind an info affordance** — "How Channel picks a path" already exists
   and is the right home for it. Detail stays available and stops being compulsory.
4. **No consolation.** *"You said no, which is a perfectly good answer"* is the app having feelings
   at the user. Delete that register entirely.
5. **Never argue a claim the OS already proves.** After `J66` the app cannot read contacts at all,
   so no sentence is needed about what it does with them.

#### Worked examples

> **Was** (190): "Browsing asks your phone for permission and reads your contacts only while this
> screen is open. Channel saves none of them and sends none of them anywhere — only the name of
> whoever you tap."
> **Becomes:** *"Pick one person. Channel never sees the rest."* — and after `J66` the screen it
> lived on is gone.

> **Was** (207): "Costs you: your device appears on our server. Turn it off and you appear nowhere
> — but the server is the only internet pathway there is, so you will only reach people in radio
> range."
> **Becomes:** *"Your device appears on our server. Off means radio range only."*

> **Was** (195): "Asks for Face ID, Touch ID or your passcode when you open Channel after a minute
> away. This covers the screen — it is not extra encryption, and your messages are already
> encrypted."
> **Becomes:** *"Face ID or your passcode to open Channel."* The "not extra encryption" caveat is
> real and belongs in the info screen, not under the switch.

**Both platforms, same strings, one pass.** The rewrite is a session of its own and must not be
done in a hurry — these sentences are the app's claims about itself, and a careless trim can turn
a true one false. Shorten by *deleting whole sentences*, never by compressing a qualified
statement into an unqualified one.

### J68. Contacts you can reach without meeting: discovery, verification tiers, and what it costs. — *Don*

Don: *"the app has to allow casual comm with strangers and not just trusted people if it's going to
be someone's goto messaging app and not just the one they use to cheat on their girlfriend."*

**He is right, and this is a strategic change rather than a feature.** Today the product is
unusable with anyone you have not physically met. That is a niche tool.

#### 1. One contact screen, which we do not have

Create a contact by typing the fields, **or** by populating them from the system picker (`J66` —
out of process, no permission), then editing what came back, then saving. On a saved contact:
**Delete · Edit · Share · Verify**.

#### 2. "Pair" is the wrong word now, and should go

It was right when pairing was mandatory. Under this model connection is automatic and the
face-to-face ceremony is an *upgrade*, so "pair" both overstates the requirement and sounds like
Bluetooth headphones. **"Verify in person."** Signal says *verify safety number*; Apple says
*Contact Key Verification*. Users arrive already knowing the word.

#### 3. The tiers exist and are not being shown

`PairingProvenance` and `IdentityConfidence` already model **verified / unverified / weak / none**
with the method that produced them. Both contact lists render a binary "Paired"/"Not paired" over
the top of it. Surfacing the real level — badge plus colour, per Don — is display work against a
model that is already built and tested.

#### 4. Discovery by number or email: the part that costs something

To let someone find you by number, a slot must be derivable from that number. The number space is
about 10¹⁰. `J40` already states the consequence: *"anyone — including our own server — can
enumerate every number... We make it worthless by guaranteeing there is never anything at those
addresses but a public key and a claim."*

Three ways to go, and they are not equivalent:

| | Enumeration | Convenience | Claim cost |
|---|---|---|---|
| **(a) Directory, WhatsApp-style** | membership list falls out | highest | loses "no phone number" outright |
| **(b) Code-gated, `J40` as written** | impossible | needs an out-of-band code | none |
| **(c) Write-only discovery** | deposits possible, **membership not observable** | high | "not required" rather than "never" |

**Recommend (c).** Anyone may deposit an invitation at a slot derived from a number. **Nobody can
read whether a slot is occupied** — there is no lookup, no "is this a user" endpoint, and a deposit
succeeds identically whether or not anyone exists. The owner polls their own slot and collects.
Enumerating all 10¹⁰ numbers therefore yields exactly nothing: you can shout at every number in the
world and never learn which ones answered.

#### 5. Two honest consequences that must not be glossed

**First contact by number is trust-on-first-use, and cannot be otherwise.** The invitation cannot
be encrypted to a key we do not yet have, so whoever polls that slot receives it — including
somebody who wrongly claims the number. That is the same exposure Signal and WhatsApp carry on
first contact, and it is exactly what "verify in person" later repairs. **The tiers are not
decoration; they are the mitigation.** A contact reached by number must never display as verified,
and the badge has to make that legible at a glance.

**Proving you control a number means somebody sends an SMS to it.** There is no way around that,
and it means a provider sees the number at verification time. Per `J40` the number is then used to
derive a slot and discarded — it must never be stored beside an identity.

#### 6. The claim change, which is the real cost

The site and both store listings currently say **"No phone number and no email address, at any
point."** After this it becomes *"not required"*, with exposure opt-in and verified. That sentence
is load-bearing on `channelmessenger.net` and in the App Store description.

**Nothing here ships until that copy is corrected everywhere.** Shipping searchable-by-number while
the front page promises otherwise is the one failure this project cannot recover from — every other
claim on that page is believed because the claims have been true.

##### J68a. Verification binds to the value, not to the field. — *Don*

Don: *"any edit of email or phone requires reverification or it can't be used to attract incoming
attention and thus can't be used as the only credential to send either if not verified."*

**The attack this closes.** Verify a number you genuinely own; edit the field to your ex's number;
you are now published as reachable at their number and collect the invitations meant for them. A
verification that attaches to a *field* rather than to a *value* is an identity takeover with a
text edit.

**So verification is derived, never stored as a flag.** Not `phoneVerified: Bool` beside a mutable
`phone`, which is one careless `save()` away from claiming a proof that does not exist — the exact
failure `Contact.isPaired` is written to make unrepresentable (*"never from a stored flag that
could drift out of step with the keys it claims to describe"*). Instead the proof is held against
the value it proves:

    verified(handle) = proofs[handle] exists and is unexpired

Change the string and no proof matches, so the handle is unverified by construction. **Void it the
moment editing begins, not on save** — otherwise there is a window in which an edited value still
wears the old proof.

**An unverified handle is inert in both directions.** It cannot be published for discovery, and it
cannot be the sole credential for an outbound first contact. It is a note to yourself, nothing more.

**And verification of a handle is not security.** A verified number makes you *reachable*; only a
key exchange makes the channel *authenticated*. The two axes are independent, and conflating them
is what would let a person read "verified" and believe something stronger than we mean:

| | Gives you | Requires |
|---|---|---|
| Verified handle | somebody can find you | SMS/email proof of control |
| Key exchange | nobody can interpose | in-person scan, or code out of band |

`J68`'s badge must therefore show the *identity* tier (`IdentityConfidence`), never the handle
tier. A contact reached by verified phone number and never key-exchanged is **Not verified**, and
must look it.

##### J68b. Security is a gradient the user walks up, never a gate they pass through. — *Don*

Don: *"the users on a case by case basis always weigh convenience vs security depending on the
needs of the moment, message, or contact... if you're just getting a message re a hotel reservation
you just made you are not going to want to spend a complex frustrating time doing a key exchange
and pairing... you just want it to work and quickly no fuss."*

**The app is currently built the wrong way round.** Pairing is mandatory, so the floor and the
ceiling are the same height: you either do the full ceremony or you cannot send a word. A hotel in
Spain will never do the ceremony, so today the app simply loses that conversation to WhatsApp — and
with it the reason to have the app on your phone at all.

Four rules follow, and they are all about what *not* to do:

1. **Zero-ceremony contact is not a "default" and not an escape hatch — it is the floor.**
   Don, correcting an earlier draft of this entry: *"it's not a default necessarily, it is just
   giving the user options... and even then you're more secure than WhatsApp even if you do
   exactly what WhatsApp does, due to the lack of tracking or selling your location."*

   That distinction is the whole strategy and the wording matters. "Default" implies the ceremony
   is the real product and the casual path is a concession. It is not. **A user who never verifies
   anybody, ever, is already better off than on WhatsApp** — no contact upload, no advertising
   identifiers, no third-party SDKs, no location telemetry reaching a broker, no account, no phone
   number required, nothing retained once delivered. None of that costs the user a single tap.

   So the user is never trading security for convenience. They are choosing how much *additional*
   security to add on top of a floor that is already higher than the alternative's ceiling. Type,
   send, done — and the security work happens later, or never, and never blocks the first message.

2. **Nothing blocks on verification. Ever.** No modal, no "verify before messaging", no disabled
   send button. The message goes.

3. **Unverified is a normal state, not an error.** The hotel stays unverified for life and that is
   *correct*, so it must look correct: a small grey label, no warning colour, no banner, and — per
   `J67` — no paragraph explaining the risk. An app that nags about a hotel booking teaches people
   to ignore the one warning that will matter.

4. **Prompt to upgrade only when something actually changed.** A safety number that changes is a
   real event and deserves attention. Elapsed time, message count, or "you have been chatting a
   while" are not events, and prompting on them is how a security signal becomes wallpaper.

**Only `IdentityConfidence.verified` is earned, and only a *change* in it is alarming.** Everything
else is quiet.

#### What this does to the positioning

The site currently sells a tool for people who have met in person. This is a better product and a
better sentence: **a messenger that works like the one you already use, which can become something
much stronger with the handful of people where that matters.** The hotel gets convenience; the
person who needs the guarantee walks up the gradient to get it. Neither is asked to care about the
other's requirements.

##### J68c. Handles: why a registry is a different kind of thing, and the version that avoids one. — *Don*

Don: *"if he used the handle how do you prove it's unique? not sure you can without saving on the
server and linking it to either the verified email or verified phone number... but what if another
user also uses @delton57?"*

**He reasoned to the right place. The distinction is that phone numbers and email addresses come
with uniqueness already guaranteed by somebody else.** The telephone system and DNS settle who
controls `+1-555-1234` and `don@example.com`, so we store nothing: prove control once, derive the
slot deterministically from the value, done. A handle has no such authority. To make `@delton57`
unique **we must become the authority**, and that is a categorically different product.

#### What a chosen-handle registry actually costs

1. **It must be readable, which reintroduces the membership oracle `J68` removed.** Number
   discovery is write-only — deposit at a slot, learn nothing. A handle you type must *resolve*, so
   there is a lookup endpoint, and handles are short and guessable. The whole namespace can be
   enumerated in an afternoon.
2. **It stores the link Don describes** — handle → verified phone or email — which is precisely the
   identity database this architecture exists in order not to have, and it is subpoenable.
3. **Squatting.** `@chase`, `@support`, `@apple`, and every ex-partner's name. First-come-first-
   served needs a dispute process, which needs a human, which is a permanent cost.
4. **Name moderation across languages.** Don's `@pussylicker` problem, in six languages, against
   homoglyphs and leetspeak. Telegram and Discord both spend real money here and neither has
   solved it.
5. **App-review exposure.** Handles plus no phone number plus privacy is a combination Apple reads
   as a solicitation platform, and Don is right that it is what the market would arrive for.
6. **Polling multiplies.** Ten profiles × every handle each, on every check. `UNIT-ECONOMICS.md`
   costs polling as the dominant request driver; handles multiply it per profile.

#### The version with no registry: a self-certifying handle

Do not let the user *choose* the unique part. Derive it:

    @delton57#K7M2       display name · suffix from the identity key

- **Uniqueness is cryptographic**, so no registry, no server state, no authority to become.
- **Impersonation is impossible** — the suffix is bound to a key nobody else holds. Someone may
  call themselves `@delton57`, but they cannot produce `#K7M2`.
- **No squatting and no dispute process**, because the display half is decoration and carries no
  claim.
- **No name moderation for uniqueness** — the display half is local, so an offensive one is a
  problem for the person who has to look at it, not a namespace we police.
- **Enumeration is infeasible** if the suffix carries enough entropy.

This is how Tor onion addresses and Bluesky DIDs work, for the same reason. The cost is that it is
worse to say aloud and easier to mistype — real, but a check code catches a typo, whereas a
registry cannot un-store an identity database.

#### Routing to the right profile is free either way

Incoming packets do not need routing. Each profile polls the slots derived from **its own** keys and
handles, so a packet is only ever collected by the profile that could derive that slot. No
server-side routing, and no cross-profile linkage — which is what `J65` requires.

#### Recommendation

**Do not build a chosen-handle registry.** Either ship phone/email discovery alone (`J68`), which
needs no server state at all, or ship self-certifying handles if the "reachable without exposing my
number" case proves real in testing. The middle option — a chosen, unique, server-stored handle —
buys memorability and costs the central property the product is sold on.

##### J68d. The request mailbox, and the temporary chat. — *Don*

Don: *"each user should have a setting that allows them to optionally block messages from people
not in their contacts and could even have a special mailbox for people wanting to message that you
could approve (by making them a contact or a temporary chat) or could just block and be done."*

`J40` already required this — *"unpaired inbound surfaces the way an unknown SMS does... the spam
decision belongs to the person"* — and it is unbuilt. Three things here are new and worth pinning.

**1. It is mandatory the moment `J68` ships, not optional.** Opening discovery by phone or email
without an inbox gate is opening a spam funnel. The gate lands in the same release as discovery or
neither lands.

**2. The temporary chat is the good idea, and it solves a problem we already had.** Every other
messenger offers accept-or-block, so the Spanish hotel from `J68b` becomes a permanent contact
cluttering the list for years. A third option — *talk, and let it lapse* — fits the gradient
exactly: the conversation works immediately, nothing is added to the address book, and it ages out
on its own. **This is what most stranger contact actually is**, and no mainstream messenger models
it.

**3. The default must be "request", never "blocked".** Defaulting to blocking strangers would break
the hotel case that `J68b` exists to protect. Strict mode — nothing from outside my contacts — is
opt-in, for people who want it and know why.

#### Two properties that come free, and one that does not

**Free:** the sender learns nothing from a pending request. Collection is client-driven and the
server confirms nothing, so an uncollected request is indistinguishable from an undelivered one.

**Free:** blocking needs no server involvement. The server is blind, so a block is purely local —
their deposits simply sit in a slot until TTL expiry.

**Not free, and must not be claimed otherwise:** because a blocked sender never receives a delivery
confirmation, a determined one can *infer* the block. WhatsApp has the same tell — one tick, for
ever. We should not pretend to solve it; sealed sender does not help, because the absence of a
receipt is the signal. Say plainly that blocking stops messages reaching you, not that it is
undetectable.

**Rate limiting is per destination token, server-side** (`J40`), which the server can do without
knowing whose token it is.

###### J68c addendum — the argument that settles it, and it is Don's

Don: *"if there's a real key exchange that chat can happen with no exposure of any identifying
handle or number or email."*

**Correct, and it collapses the case for handles.** A key exchange exposes nothing at all — no
number, no address, no name, not even to our own server, because the destination token derives from
the shared secret and rotates. So the "I met someone and we both want to talk without swapping
numbers" case, which sounds like the reason to want a handle, is already served *better* without
one.

What remains is only: **findable by someone you cannot meet, and unwilling to give a number to.**
Real — a tip line, a support desk, a journalist, somebody advertising a service — but the smallest
of the three needs and by far the most expensive, since it is the only one requiring us to run a
namespace.

**Decision: no handles in v1.** It is the easiest feature to add later and the hardest to withdraw:
a registry, once populated with real people's chosen names bound to verified numbers, can never be
un-stored. If it is ever added, self-certifying only (`@delton57#K7M2`), never chosen-and-registered.

**And the tiering is a better way to describe the product than the site currently manages.** Three
ways to be reached, and the user picks how much to expose — *nothing*, *a number you already give
out*, or *a name that is not you*. The strongest option costing the least exposure is the whole
argument, and `channelmessenger.net` does not currently make it.

##### J65a. Verification is device-wide; exposure is per-profile; copying a card must never copy exposure. — *Don*

Don: *"a number already verified as yours doesn't have to reprove in another profile on the same
machine... you could copy from an existing contact as the start and then change the fields you want
to change."*

**Both are right, and each needs a guard.**

#### Verification and exposure are different facts

| | Scope | Why |
|---|---|---|
| **Verified** — this device proved control of `+1-555-1234` | **device-wide, reused freely** | a local fact that leaks nothing; re-proving it per profile is friction with no benefit |
| **Exposed** — *this profile* is findable at `+1-555-1234` | **per profile, and at most one** | publishing it links that profile to the number for anybody who knows it |

**Two profiles must never publish the same handle**, and this is a hard constraint rather than a
preference, for two independent reasons:

1. **It is a worse leak than the one `J65` already forbids.** A shared identity key links two
   profiles for someone paired with both. A shared *published number* links them for **anyone in
   the world who knows that number** — no relationship required.
2. **It does not work.** The slot derives from the handle, so both profiles poll the same address
   and both collect everything sent to it. Routing is ambiguous at the protocol level.

So: the proof store is device-wide; the publication list is per-profile; and publishing a handle
already published by another profile is refused, not warned about.

#### Copying a self card must copy the presentation and never the publication

Starting a new profile from an existing card is genuinely useful — name, avatar, the text fields,
all worth carrying over and editing.

**But the copy must drop every exposed handle, unconditionally.** The overwhelmingly common use of
this feature is *"make a pseudonym profile like my real one"*, and carrying exposure across would
publish the user's real phone number on the anonymous profile at the moment of creation — silently,
as a side effect of a convenience. That is the single worst outcome the profile feature can produce,
it is unrecoverable once someone has found them by it, and it would look exactly like the feature
working.

Copy the face. Never the address.

##### J65b. Several identifiers per profile is an OR; reorganising must not orphan mail. — *Don*

Don: *"if you choose to expose multiple identifiers on the same profile then it's an or... they can
change at any time how they organize their profiles as long as they pull pending mail first so they
don't orphan a message in transit."*

**Several identifiers on one profile is an OR**, and it falls out of the design: each verified,
exposed identifier derives its own slot, and all of that profile's slots drain into one mailbox.
Splitting identifiers across profiles is the same mechanism used the other way. Both are legitimate
and the choice is the user's.

Cost, which caps the feature: every exposed identifier is another slot polled on every check, per
profile. `UNIT-ECONOMICS.md` puts polling as the dominant request driver, so identifiers × profiles
is the multiplier to watch, and both need ceilings.

#### The orphan, and why it must not be the user's job to avoid

Don is right about the hazard and I would invert the remedy. Unpublishing an identifier while a
message sits at its slot means **nothing polls that address any more**. The envelope waits out its
TTL and expires. The sender saw no error; the recipient never knew. **A silently lost message,
invisible to both parties, is the worst failure this product can produce** — worse than a crash,
because a crash is noticed.

"Pull first" is an instruction, and people reorganise profiles late at night on a phone with no
signal. Even a diligent user cannot win: a message can land between their check and their change.

**So the app guarantees it instead of asking:**

1. **Drain as part of the operation, not before it.** Changing a handle's profile or unpublishing it
   first collects that slot, delivers what is there, and only then applies the change. It is one
   step to the user, and the ordering is not theirs to get wrong.
2. **Keep draining the old slot for the full TTL after unpublishing.** The identifier stops being
   advertised immediately; the mailbox keeps being emptied for as long as an envelope could still be
   sitting in it. This closes the race entirely — mail deposited a second before the change still
   arrives — and costs one extra slot poll for a bounded window. It is what a forwarding address is
   for.
3. **Moving a handle between profiles drains to the old profile first, then switches.** Mail
   deposited before the move was addressed to that persona, and deterministic beats clever.

#### And the documentation point, which is `J67`'s rule 3

Don: *"the docs need to explain well how all this works so people can choose intelligently."* Yes —
**in the docs.** One line at the control, the model explained properly in a help page. This is
exactly the material that has been leaking onto settings screens and making them unreadable.

### J69. Three kinds of claim, never blurred. — *Don's positioning, made operational*

Don: *"we do not need to overhype or over claim and can gain more trust being honest about
limitations than we can by promising the moon with footnotes... even at our worst we'll be more
secure than most competitors and at our best we'll be hard to match."*

Right, and the intent is not the hard part. Everyone intends this. What fails is **drift** — a
promise that started as "we have configured it not to" gets repeated until it is written as "it
cannot", and nobody notices the sentence changed. So every claim we publish is one of three kinds,
and the kind is stated, not implied:

| Kind | Enforced by | Example | If we are lying |
|---|---|---|---|
| **Architectural** | mathematics | *the relay cannot read your messages* | provable by anyone with the protocol |
| **Policy** | our configuration and our word | *we do not log IP addresses* | undetectable from outside |
| **Aspiration** | nothing yet | *we would resist a demand* | worthless as evidence |

**The rule: never let a policy claim be phrased as an architectural one.** "We do not log IPs" is
true and it is `netlify.toml`'s doing; it is not the same species of statement as "we hold no key".
A reader who cannot tell them apart cannot calibrate, and when they eventually learn the
difference they will discount everything — including the claims that were airtight.

#### Where this bites us today, specifically

- **IP logging is policy, not architecture.** `netlify.toml` disables it. Our host could log
  regardless; nothing but configuration stops it. The site should say so in those words.
- **"No third-party SDKs" is architectural today and one dependency away from policy.** ML Kit
  (`MLKIT-TERMS.md`) would end it.
- **Blocking is detectable** (`J68d`) — say it stops messages reaching you, never that it is
  invisible.
- **Handle and phone discovery are structurally weaker than key-only pairing** (`J68`), and a
  subpoena reaches different amounts in each case. The three-row table in that entry belongs in the
  white papers close to verbatim.
- **And the largest gap: nobody has audited any of this.** Signal's claims carry a decade of
  external scrutiny; ours carry our own care. Careful design is not verification, and until there
  is a published third-party review the honest comparative is *"built on the same primitives,
  not yet independently examined."* An audit is therefore not marketing spend — it is the thing
  that converts our architectural claims from assertions into evidence.

#### What this buys, and it is the actual strategy

A product that names its own limits gets believed about the things it does not qualify. That is
worth more than any superlative, and it is the only durable advantage available to a small team
competing with companies whose security claims are underwritten by their marketing budgets.

##### J69a. Name the adversary, name what we do not control. — *Don*

Don: *"protect what you can protect, control what you can control but realize we aren't kings of
the universe either... if you're in the business of doing things that attract nation state security
agencies you can expect nation state resources... but if your concern is the jealous girlfriend or
your employer using wifi sniffers we gotcha covered."*

**This is the shape the white papers should take.** Not "Channel is secure", which means nothing,
but *"against this adversary, here is exactly what happens."*

#### The ladder, honestly

| Adversary | How we do |
|---|---|
| **Advertisers, data brokers** | **They get nothing.** No SDKs, no identifiers, no telemetry to buy — structural, not policy. |
| **Someone on your Wi-Fi** | Covered. Content is sealed on every path. The LAN rung reveals *that* you run Channel to others on that network (`J21` says so, and the setting can be turned off). |
| **A partner with your unlocked phone** | Screen lock (`AppLock`) helps and is honest about being a screen cover, not a second layer of encryption. |
| **Your ISP or a network observer** | Content sealed; ECH hides which host you asked for **only if** the API is fronted by Cloudflare, which today it is not (`CLOUDFLARE-SETUP.md`). |
| **A subpoena to us** | Little to hand over, and less for a key-only profile than a handle-bearing one (`J68`). |
| **A subpoena to Netlify, Telnyx, Apple, Google** | **Outside our control entirely.** |
| **A nation state with device access** | **We lose, and so does everyone else.** Say it plainly. |

#### What we do not control, named rather than gestured at

- **The operating system.** Apple and Google can read anything on the device — keyboard, screen,
  memory — and we would never know. Every messenger has this ceiling.
- **App distribution.** Either store could serve a **modified build to one user**. This is the
  attack self-checksumming pretends to address and cannot, because the checker ships in the same
  binary. The real defence is reproducible builds from published source, which a closed-source
  product cannot offer. **Name it as a limitation rather than papering over it.**
- **The host.** `netlify.toml` disables access logging; Netlify could log regardless. Policy, not
  architecture (`J69`).
- **Telnyx**, which sees every number we verify.
- **The person you are talking to.** No messenger protects against the recipient — screenshots,
  a shoulder, their compromised phone. Users underestimate this more than everything above it
  combined, and it deserves the plainest sentence we can write.

#### And the framing Don gave, which should survive into the copy

**Risk tolerance against utility, chosen per use — like a password.** Nobody uses a 40-character
passphrase for a crossword site. Our job is to make the strong option available and cheap, the
weak option honest about being weak, and the choice legible — not to force everyone to the top of
the ladder and lose them (`J68b`).

### J70. Screen capture, disappearing messages, view-once — and the camera pointed at the screen. — *Don*

Don: *"there are ways to block screen recording and screen shots on the device but not recording
from a camera or second phone so worth preventing just like expiring messages that whatsapp allows
and single view photos even again with same limitation above."*

Worth building, all three. The reason this needs an entry rather than a line on a list is that
**the two platforms can do very different amounts here, and saying so is `J69`'s rule in its first
real test.**

#### Android can actually block it. iOS cannot.

| | Android | iOS |
|---|---|---|
| Block screenshots | `FLAG_SECURE` — genuinely prevents it | no *official* API, but the secure-layer technique works — see correction below |
| Block screen recording | `FLAG_SECURE` covers it | same technique covers it |
| Hide from the app switcher | `FLAG_SECURE` covers it | possible by other means |
| Detect a screenshot after the fact | no | `userDidTakeScreenshotNotification` — **after**, never before |
| Detect screen recording | no | `UIScreen.isCaptured`, and content can be blanked while it is true |

**CORRECTION, same evening — Don sent a screenshot that disproves the row above.** He captured
WhatsApp's Contact info screen on iOS and the capture came out black, reading *"Screen capture
blocked — To protect everyone's privacy on WhatsApp, this screen capture has been blocked."*

So **iOS blocking is achievable and is shipping at WhatsApp's scale.** The mechanism is the
`isSecureTextEntry` layer technique: a secure `UITextField`'s layer is excluded from the capture
pipeline, so real content added as a sublayer of it renders normally on screen and black in any
screenshot or recording. It is undocumented, and I dismissed it too quickly on that basis. Apple
plainly tolerates it, and it is in wide production use.

Two things the screenshot teaches beyond "it works":

1. **You control what appears in the capture, not merely that it is blank.** WhatsApp substitutes a
   branded explanation, which means a second layer *outside* the secure one renders the message. A
   black rectangle reads as a bug; an explanation reads as a feature.
2. **They apply it per screen, not app-wide.** This was Contact info. Blanket blocking would break
   legitimate uses and irritate people; the right unit is the screen holding something sensitive.

Revised position: **implement it on iOS, describe it as blocked, and treat it as best-effort in
the engineering sense** — pinned by a test that fails loudly if a future iOS release changes the
behaviour, since it will fail silently otherwise, and a privacy control that has quietly stopped
working is worse than one that was never claimed.

So the copy is the **same** on both platforms after all — *screenshots are blocked* — which is the
better outcome for `J65`'s identical-apps rule and, unusually, the result of the evidence being
better than my assumption rather than worse.

#### The limitation Don names is the important one

**A second phone pointed at the screen defeats every one of these, on both platforms, completely.**
So does a photograph of a monitor, and so does the recipient simply remembering. These features
raise effort and create a social signal; they do not create a technical guarantee, and Signal
documents its own disappearing messages in exactly those terms.

**They must therefore never be presented as protection against the person you are talking to.**
`J69a` already records that the recipient is the adversary users underestimate most; this is the
feature most likely to make them underestimate it further. One line at the control — *"they may
still photograph the screen"* — and nothing more.

#### Scope

- **Disappearing messages** need a protocol field and both sides enforcing locally; `WHATSAPP-GAPS`
  puts them in Tier 3 for that reason. Deleting on a timer must also delete from the store, not
  merely hide.
- **View-once photos** are the same mechanism plus a display mode, and the attachment must never be
  written to disk decrypted.
- **Screen-capture resistance** needs no protocol at all and is cheap on Android, so it lands in
  phase 1 with the rest of the UI work.

##### J70a. The blocked-capture card is where the honest caveat belongs. — *Don's screenshots*

Don confirmed the mechanism by using it: *"try to screen shot a profile photo in whatsapp and you
get an image of something else and the screen shots saved are the images i sent you."* The two
images he sent **are** the substituted output — and the first shows iOS's Markup editor already
holding the replacement card, so the substitution happens at the compositor **before** the editor
ever sees it. Nothing is detected and deleted after the fact; the file written to Photos was never
the real content.

#### Put the caveat on the card, not under the control

`J70` says the camera-pointed-at-the-screen limitation needs saying, and `J67` says settings screens
must stop carrying paragraphs. Both are satisfied by putting it where WhatsApp puts its branding:

> **Screen capture blocked**
> Someone can still photograph this screen with another camera.

That sentence reaches a person **at the exact moment they tried to capture something**, which is
the only moment they are actually thinking about it — and it costs zero space in the ordinary
interface. It is the most honest possible placement, and it is free.

#### Which screens get it, and which deliberately do not

| Screen | Blocked? | Why |
|---|---|---|
| **Expanded** profile photo | **yes** | Don, on where WhatsApp actually does it: *"it was the profile image after i expanded it"* |
| Avatar thumbnail in a list | **no** | incidental at that size, and blocking it would black out whole list screens |
| Contact info (the card itself) | **no** | Don, testing it: *"they let me screen capture the entire contact card that includes the little profile pic but not the expanded version"* |
| View-once media | **yes** | the entire feature is void without it |
| **Your own pairing QR / code** | **no** | people legitimately screenshot it to send by another channel, and we support sharing it as text anyway |
| **Safety numbers** | **no** | comparing them by screenshot over a video call is a legitimate verification path |

**The rule the list follows:** block a screen where the content is presented *to be looked at*,
never where it appears incidentally. An expanded photo was opened on purpose; the same photo at
32pt in a contact row is furniture, and blacking out every list that contains one would be absurd.

Blanket blocking would be easier and worse. Two of these screens exist *in order to be shared*, and
blocking them would break the ceremony `J63` depends on.

**Why the earlier reading of Don's screenshot was wrong, recorded because the mistake is
instructive.** The substituted card said *Contact info* at the top, so I concluded the contact
screen was blocked. It is not. The expanded photo viewer is presented over that screen and
**inherits its navigation title**, so the blocked capture wears the name of the screen underneath
it. Blocking is narrower than the artefact suggests, and a substituted screenshot is evidence about
what was on top — not about what the screen is.

Don also notes WhatsApp applies it in *"a couple other places too"*. Settle our own list from the
principle above rather than reverse-engineering theirs — but view-once media is certainly among
them, since the feature means nothing otherwise.

### J71. One field that takes a handle, a number, an email, or a search term. — *Don*

Don, on starting a new chat: *"you list the contacts but need a blank field too where a user would
enter a handle, email, phone number, or a search term for the list of contacts that follows...
much like WA — note the pattern here?"*

**The pattern noted.** The same control keeps being needed in three places, and each has been
designed separately or omitted:

| Screen | Today | Should be |
|---|---|---|
| New chat | list only, no field | one field |
| Contacts | no search at all | the same field |
| Add contact | separate "Add by Name" and pairing routes | the same field |

**One control, in all three, doing one thing: you type, and it narrows or resolves.**

- Type letters → filters the contact list beneath it.
- Type a phone number or an email → offers to reach that person (`J68`), if the handle is verified
  and exposed.
- Type a handle → the same.
- Nothing matches → offers to add by that name.

**Why this is not four features.** Every one of those is *"who do you want to talk to"*, and
WhatsApp answers it with a single field because that is the only question the user is actually
asking. Splitting it into a picker, a search box, and an add-by-name route makes the user choose a
mechanism before stating an intent, which is backwards.

**It also degrades correctly while the rest is unbuilt.** With no discovery, the field is search
plus add-by-name — useful today with three contacts, essential at three hundred. Each `J68`
capability lights up inside the same control as it arrives, and no screen has to be redesigned to
receive it.

**Build the field now, in all three places.** It is phase 1 work, it needs nothing from phase 6,
and it is the shape the rest has to fit into.

### J72. Once it is public we cannot recall a version. Plan for permanent old clients. — *Don*

Don: *"once this app goes public we lose control of what version end users use so we have to be
backwards compatible whenever possible... it's possible that some functions might be patchable at
the server level if the hooks in the app are well designed so think about that possibility."*

**Tonight demonstrated the failure.** An emulator running **0.2 (build 7)** could not pair with one
running **2.002 (build 13)**: the old build emits a 32-byte code and rejects the 48-byte one, so
each device correctly refuses the other and pairing is impossible in both directions. I fixed it by
upgrading both. **In the wild that option does not exist**, and two Channel users would simply be
unable to reach each other, for ever, with no way to tell whose fault it was.

#### Rules that follow

1. **Every wire format carries a version, and old versions are accepted while it is safe to.** The
   pairing payload grew from 32 to 48 bytes with no version byte, so the only available response to
   an old code is refusal. A leading version byte would have let the new build accept the old form
   and say what was lost.
2. **When compatibility must break, refuse with a message that names the fix.** `PairingPayload`'s
   `isLegacy` already does this — *"Their Channel is out of date"* — and that is the pattern.
   Refusing is acceptable; refusing silently is not.
3. **Ship a version floor the client can check.** The client asks the server what the minimum
   supported version is and tells its own user when it has fallen below it. That is a fix that
   reaches an old client without an app update.
4. **Test cross-version pairing in CI.** Two builds, N and N-1, must pair — or fail with the
   actionable message. Tonight's defect would have been caught the day it landed.

#### What may move server-side, and the line that must not be crossed

Don's instinct is right and it has a hard boundary, which is the same split as `J69`:

| May be server-controlled | Must never be |
|---|---|
| TTLs, rate limits, poll cadence hints | which cipher or key agreement is used |
| Minimum-supported-version notices | key lengths, padding buckets that affect confidentiality |
| Endpoint routing and retry policy | whether a message is encrypted at all |
| Feature flags for **UI and product** behaviour | anything a downgrade attack could exploit |

**Every hook that lets the server change client behaviour is a hook an attacker or a compelled
operator can pull.** A server that can tell a client which algorithm to use can tell it to use a
weak one, and our own threat model (`J69a`) explicitly includes a subpoena to the host. So:
**the server may change policy; it may never change cryptography.** Anything in the right-hand
column stays compiled into the binary where a server compromise cannot reach it.

That still leaves real value in the left column — most operational emergencies are timing, load and
routing, not cryptography — and it is the honest version of Don's *"not all web type fixes have to
be visible to the user."*

##### J72a. Additive, not breaking: an old version may miss a feature, never lose one. — *Don*

Don: *"we could require a software update if something really bad or really good happens... but to
the extent possible we don't want to do things that break old versions for the key version
dependent things. an old version could miss a feature but what the old version did well should
hopefully still work as it always did if new versions gain capability."*

**That is the rule, and the pairing payload is the counter-example to learn from.**

Growing the code from a 32-byte identity key to a 48-byte key-plus-nonce was made a **breaking**
change when it could have been **additive**. What actually happened tonight: the old build rejects
48 bytes, the new build rejects 32, and two Channel users can never reach each other in either
direction.

**What additive would have looked like:**

    [version byte][32-byte identity key][optional trailing fields...]

An old client reads the identity key it understands and ignores the tail. It **loses the
rendezvous** — it still needs the second scan, a missing *feature* — but **pairing still works**,
which is what it did well before. The new client sees no nonce, notes the peer is old, and falls
back to the two-scan ceremony automatically. Nobody is stranded and nobody is told to update in
order to do a thing they were already doing.

**The design rule, for every wire format from here:**

1. **Version first**, so a reader knows what it is holding before it parses.
2. **Append, never redefine.** New fields go on the end; existing fields never change meaning or
   length.
3. **Unknown trailing bytes are ignored, not fatal.** This is the single rule that makes old
   clients survive.
4. **Feature loss is the acceptable degradation. Function loss is not.**

**A forced update remains available for a genuinely serious event** — a cryptographic break, a
protocol flaw — and `J72`'s version floor is the mechanism. It is the emergency lever, not the
routine answer to having shipped something new.

##### J73. Flight 3 was filtered out by shape, so pairing never completed on the displaying side.

**This is why messages did not arrive between two devices, and it was one line on each platform.**

Flight 3 (`PAIR_CONFIRM`) comes back on the same rendezvous slot flight 1 used, so the listener has
to tell them apart. Both platforms did it by shape:

    if (decodeFirstFlight(candidate.envelope) != null) continue     // Kotlin
    guard decodeFirstFlight(candidate.envelope) == nil else { … }   // Swift

`decodeFirstFlight` is a **splitter, not a discriminator**: it returns a value for anything longer
than 32 bytes, which every `PAIR_CONFIRM` is. So the displaying device skipped flight 3 for ever,
timed out at 60 s, and never stored the contact — while the scanner completed and showed a
confirmation code. Two people who had just watched their digits match could not message each other
in either direction, and neither screen said why.

**The fix is to match on the envelope's id**, which is exact. By the time flight 3 can arrive we
already know the ids of everything else in the slot — flight 1, and any junk walked past on the way
to it — so carrying that set forward makes the test exact and independent of packet length.

**Deliberately not "try each candidate until one verifies".** `Handshake::on_packet` calls `abort()`
on any error and `Failed` is terminal, so probing an envelope to discover its kind would destroy a
pairing that was about to succeed. The id test costs nothing and cannot misfire.

**Verified on two emulators**: before, the scanner completed and the displayer never did; after,
both show `7446 0595 7528`, both store the contact, and a message crosses in both directions with
the sender advancing `SENT → DELIVERED`.

**The lesson worth keeping: a helper that parses is not a helper that identifies.** `decodeFirstFlight`
never claimed to answer "is this a first flight?" — it answers "split this as one". Reading it as a
predicate is what cost us this, and the name invited it.

##### J73a. Flight 3 is deleted only after the core accepts it.

Android confirmed (deleted) flight 3 inside `awaitConfirm`, before the caller fed it to the
handshake; iOS deleted it after. Android's order meant any failure between the delete and the
completeness check threw away the only copy of flight 3 in existence — and the scanner had already
moved on and would never send it again. The two now agree on iOS's order. Found by Grok, verified
against the code.

##### J74. A stopped listener must say so, or the user is trapped.

`RendezvousPairingListener.stop()` freed the Rust handle but left `_state` on `Completed`. The
screen drives its dialog off `state as? Completed`, so:

1. "Numbers Match" saved the contact and called `stop()`;
2. the dialog re-rendered immediately, because the state still said `Completed`;
3. the freed handle made `confirmationCode` return null, and the code sat behind `?.let` — so the
   dialog came back **without the digits**, asking someone to confirm that numbers match while
   showing them no numbers;
4. neither button could close it, and every tap on the confirm button added the contact again.

That is how a device ended up with the same contact listed twice.

Two changes, and the second matters more than the first:

- **`stop()` sets `State.Idle`.** A stopped listener is not a completed one, and a dismissed dialog
  stays dismissed.
- **The confirm button is disabled when there is no code**, and the dialog says why. Confirming is
  the one action in this app that cannot be undone by any amount of later care — it is the moment a
  key becomes trusted — so it must be impossible to take blind. A missing code is now a visible
  refusal instead of a silently absent paragraph.

##### J75. "Messages don't arrive" was a default, not a defect — and I nearly reported it as one.

After `J73` fixed pairing, messages still appeared only when **Check Now** was tapped. Every
foreground poll was missing. I read the loop, found `replaySpool()` sitting unguarded ahead of the
`while`, and had a tidy story: it throws, `SupervisorJob` swallows it, polling is dead for the
process lifetime, and Check Now still works because it calls `collectOnce` directly. It fit every
symptom.

It was wrong. One log line settled it:

    tick fg=true interval=900000

The loop was alive and ticking every 15 s the whole time. `interval` was **fifteen minutes**,
because the default cadence is `NORMAL` — and `NORMAL` polls every fifteen minutes *including while
the app is open on screen*. Nothing was broken. A message could simply sit on the relay for a
quarter of an hour while the recipient watched the conversation, and Check Now was the only thing
that looked like it worked.

**The default is now `EVERY_MINUTE`, and it costs nothing.** `EVERY_MINUTE` and `NORMAL` share the
same `serverWakeIntervalMinutes` (15), so the background floor, the wake-ups, the battery while
closed and the server cost are all unchanged. The only difference is how often an app the user is
actively looking at asks for mail — the one moment they are paying attention. Measured after the
change: **61 seconds, unaided.**

**Two things worth keeping.**

*On the diagnosis.* A plausible mechanism that explains every symptom is not evidence. I had read
the code, built a coherent account, and was ready to write it up; the account survived because I
had not yet looked. `Log.i` cost two minutes and was the only thing in the whole sequence that
actually distinguished the hypotheses.

*On the product.* An app whose default is "up to fifteen minutes, even while you're staring at it"
will be judged broken by every person who tries it, and they will be right to. Responsiveness while
the screen is on is not a battery setting — it is whether the thing works.

##### J76. Opt-in hints for novices; screens stay clean for everyone. — *Don*

Don: *"we might create a hint feature that a novice user could turn on that would pop up
explanations when certain changes are made but still keeping extra text off the screens at least.
an expert might be insulted by them but it's an option for the non nerds."*

This resolves the tension between `J67` (cut the explanatory copy — "if you need that much text
you're probably lying") and the fact that some of what this app does is genuinely unfamiliar. The
answer is not to put the text back; it is to make it **opt-in and event-driven**: a hint appears
once, at the moment a setting changes or a state is first entered, and only for a user who asked
for hints. Screens carry no more text than WhatsApp's.

Design notes for when this is built (Phase 1 backlog, after the contact editor):

- One switch in Settings: **Show hints**. Off by default? *Open* — on-by-default reaches the people
  it is for, but the first-run flow could ask instead.
- A hint fires on a **change**, not on a screen: turning Internet off, first pairing completed,
  first unverified contact, first disappearing-message timer. Each hint fires **once** per install.
- Copy lives in the same i18n tables as everything else (`J39`).
- Never a hint that blocks: a dismissible card or snackbar, not a modal.

##### J77. Poll every few seconds while someone is actually in a chat. — *Don*

Don: *"the app is open, you might be actively texting with somebody, in which case you really want
to have instant replies. So one minute polling is not instant, but it's probably tolerable to someone
who is elected to not enable push."*

**Two corrections to the premise, both verified against the code tonight, and they change the
answer.** First, nobody has push today: `_shared.mts`'s `wake()` is an empty function ("No
credentials yet") and neither client calls `/v1/register`. Second, even once it exists, `J10`'s push
is a **contentless heartbeat about every half hour**, decoupled from mail on purpose — it will never
make a reply instant. So instant-while-chatting has to come from the foreground poll loop, for
everyone, push or not.

**The change.** The foreground interval drops to **5 s** in exactly two situations, and is the
user's cadence otherwise:

1. a conversation screen is on screen (`setConversationOpen`, told by the view);
2. this device deposited a message in the last **2 minutes** (`expectReplySoon`, called after every
   successful deposit — sending is the strongest signal a reply is coming).

Both are gated on the app being in the foreground and on the cadence not being ON_DEMAND, so "no
timer" still means no timer. The tick now sleeps `min(tick, interval)` so a 15 s tick cannot quietly
cap a 5 s interval. Identical on both platforms; on iOS it rides `TransportControls` with a default
no-op so the radios need no change.

**Cost.** Idle on the conversation list: unchanged. In a chat: 12 requests/min per device, only
while the chat is open. That is the honest price of "instant" without push, and it is paid by
exactly the people getting the benefit at the moment they want it. If it ever matters at scale, the
right next step is **long-polling on `/v1/collect`** — the server holds the request open until mail
arrives or ~10 s passes — which gives lower latency *and* fewer requests than any client-side
interval. Not built now; noted so the option is not forgotten.

**What "instant" means here.** Sub-5-second arrival, measured below. Not zero: that requires the
server to push, which `J9` rules out for a stable identifier and `J10` deliberately does not do.

##### J77a. Adaptive decay: a conversation becomes an email. — *Don*

Don, refining `J77`: *"every message sent triggers 5 one minute polls followed by 5 2 min followed
by 5, 10, 20 etc if no more messages are sent or received, with sends vs receives having different
effects... a receive might trigger quick checks briefly as sometimes people send more than one
message back to back... when you send the polling pace stays rapid a bit longer as you're waiting to
see if a quick reply is coming, and if it takes more than a few min to get the next reply you can
afford to wait longer as it's no longer a conversation and is more like an email."*

Built as stated, on both platforms, with the same numbers:

| Since last **send** | Poll every | | Since last **receive** | Poll every |
|---|---|---|---|---|
| < 5 min | 1 min | | < 2 min | 1 min |
| < 15 min | 2 min | | < 6 min | 2 min |
| < 40 min | 5 min | | < 20 min | 5 min |
| < 90 min | 10 min | | after | floor |
| after | floor | | | |

The two ladders are read separately and the **shorter wins**. A conversation on screen in the
foreground still drops to 5 s (`J77`). **The user's cadence setting is the idle floor**: activity
may only make polling more frequent than the setting, never less — a person who chose "Every minute"
gets every minute at rest, and "About every 15 minutes" now means *at rest*.

**Platform truth.** Android runs this schedule foreground and background, because `MeshService` is a
foreground service. iOS runs it only while the app is alive; a backgrounded iOS app cannot poll on a
timer, and nothing here pretends otherwise.

**"Don't let the phone sleep": no.** On iOS it exists only while the app is on screen, where this
schedule already applies; on both platforms it costs the battery more than it saves the server.

**On observability.** Don: *"i have my doubts that they'll learn much more from this than they would
from every min polls."* Agreed. `J9` already concedes the poll is the one thing the server sees; a
rhythm that decays after activity reveals that activity happened, which the deposit that caused it
already revealed.

##### J78. Deleting a contact leaves the page. — *Don*

Don: *"deleting should bring you back without having to hit the left arrow to leave the screen."*
iOS `ContactDetailView` deleted the contact and stayed put, falling into its "Contact Removed"
placeholder — which in dark mode reads as a blank screen. Now `dismiss()` follows the delete, and an
`onChange(of: contact == nil)` pops the page if the contact vanishes for any other reason — the
same shape as Android's `LaunchedEffect(contactId) { onBack() }` from earlier tonight. Android
re-verified live: delete returns straight to the Contacts list. Shipped as iOS 2.004 (10).

##### J71a. Built: one field on the Contacts list, both platforms.

The same control on Android `ContactListScreen` and iOS `ContactListView` (which is also the
New-Message picker, so all three of `J71`'s screens get it from one implementation): a field above
the list, placeholder *"Name, number or email"*. Letters narrow the list by name. When **nothing**
matches, one row offers *Add "<what you typed>"*, which creates an unpaired contact and opens their
page with Pair on it. Not offered beside a partial match — typing "Dev" to find DeviceA must not
also propose a new "Dev". The separate iOS "Add by Name" sheet still exists behind the + menu; it
is now redundant and goes when the contact editor lands. Numbers, emails and handles are treated
as names until Phase 6 discovery exists — there is nothing yet to look them up in.

Verified live on Android: filter, add, detail, return. iOS builds; same code shape.

##### J79. The contact card: name, phone, email — local only, filled by hand or from the phone's picker.

Don: *"a screen on contacts where you create a contact and you can fill in the text fields or select
a contact from your phone to populate the fields."* Built on both platforms:

- `Contact` gains optional `phone` and `email`. **Local only, like the name**: never sent to the
  peer, never to the server, never used as an address. Discovery (`J68`) will need a *verified*
  identifier, which is a separate later thing — a typed number on a card proves nothing.
- Additive on disk (`J72a`): old stores decode unchanged, defaults are null.
- The system picker (`J66`, still no contacts permission) now returns name + first phone + first
  email for the one contact chosen, and they land on the card. On Android that is one more query
  under the same one-shot URI grant; on iOS the `CNContact` the picker hands back already has them.
- Android: the pencil on the detail page opens *Edit contact* (name, phone, email, Save); the card
  shows whichever fields are filled and nothing for empty ones (`J67`). iOS: phone and email are
  edited in place under the name, exactly as the name already was.

Verified live on the Android emulator: edit, save, card, and the values on disk.

##### J68e. Built: Delete · Edit · Share · Verify in person on the contact page, both platforms.

"Pair with X" is now **Verify in person** (Android) / **Verify in person** with a seal icon (iOS).
**Share** sends the card as plain text — name, phone, email — through the system share sheet;
nothing about keys, because a shared card is how to reach someone, not an identity claim. Edit is
the pencil (Android) or in-place fields (iOS); Delete was already there. Stale copy promising "the
same six numbers" corrected — the confirmation code has been twelve digits since the hostile review.
Verified live on Android (labels, and the chooser opens with the three lines). iOS builds.

##### J70b. Built: screen-capture blocking on both platforms, on by default.

Android: `FLAG_SECURE` on the window, applied the moment the setting changes. Verified on the
emulator — a screenshot of the app comes back blank. iOS: the whole app sits inside a secure-text-
entry layer (`ScreenCaptureShield`), the mechanism `J70a` observed WhatsApp using; the compositor
substitutes a blank in screenshots, recordings and the app switcher. If the canvas layer cannot be
found the content shows unshielded rather than blank — a messenger that hides its own messages from
its user has failed harder than one that can be screenshotted. Settings: **Block screenshots**, one
line under it: *"A camera pointed at the screen is not stopped."*

##### J67a. Copy cut, first pass: Contacts stripped, Settings one line per control, both platforms matched.

Don: *"on ios app you still have explanatory prose we don't need on the contacts page for example and
too much on settings. eliminate from contacts page and shorten a lot on settings page and match on
android of course."* Contacts carries no sentences now — the self row says "You", the empty state
says "Type a name above to add someone." Every Settings helper is one short line with the same words
on both platforms; the pathway explainer is three lines. Comments in the code keep the reasoning the
screens no longer carry.

##### J68f. Built: identity-confidence tiers instead of the paired/not-paired binary, both platforms.

Every contact row and detail page now says **Verified**, **Not verified**, **Weak pairing** or
**Not paired**. Android gains the provenance fields iOS already had (assurance, numbers confirmed,
when); both platforms now *record* them at pairing — which iOS never did, so every iOS contact had
been reading as a weak pairing under its own model. Tapping "Numbers Match" on a rendezvous pairing
is the comparison, so it records Verified. **A contact stored before these fields existed shows Not
verified, never Weak** (`J72a`): weak is a claim about how a pairing was made, and an absent record
makes no claim. Verified live on two emulators: fresh pairing → Verified on both; pre-existing →
Not verified; unpaired → Not paired.

##### J65c. Built: the channel letter on every conversation row; All / Paired as one control on both platforms.

`J65` asked for the status letters to be always visible. The live bar inside a conversation already
existed on both platforms; now each row on the Messages list also carries the letter of the channel
the **last message actually travelled** (B, W, L or I), beside its time — the same `MessageChannelMark`
both platforms already used inside threads. And the Contacts filter is the same control on both:
a segmented All / Paired row (Android had a "Paired only" chip).

##### J80. Pairing code: version byte, 8-byte nonce — 66 characters, still base32. — *Don*

Don, asked whether to go denser: *"yes do as you suggested."* The payload is now `[version 0x01][32-byte
identity key][8-byte attempt nonce]` — 41 bytes, **66** Crockford base32 characters, down from 77.
Eight bytes is 64 bits of unguessability for a slot that lives minutes; the version byte is `J72a`
applied before anyone is public. Decoders accept longer payloads and ignore the tail. **Not base58**:
it would buy about nine more characters at the price of case-sensitivity, which phone keyboards and
read-aloud both punish, and would give up the no-I/L/O/U property that makes the code retypable. If
testing shows people type codes rather than scan or Send them, the bigger lever is an online-only
short code carrying just the nonce (~26 characters) with the key travelling through the rendezvous
slot — a second format, deliberately not built yet.

2.006 and earlier cannot read the new code and this build cannot read theirs; nobody public is
affected, and the version byte is what prevents this from happening again.

##### J65d. Built (Android first): profiles — one identity and one store each, picker top-left, cap 10.

`ProfilesStore` keeps `profiles.json` above every profile's own directory; each profile has its own
identity secret (keyed `secret:<id>` in the encrypted store) and its own `conversations.json`,
attachments and spool. **On first run the one legacy identity and store become profile "Me"** —
moved, not copied. Switching tears down the previous profile's store and transports and binds the
new profile's, and the navigation host is keyed on a generation counter so no screen keeps stale
state. The avatar (initials on a colour derived from the profile id) sits top-left on Messages,
Contacts and Settings; Settings shows the profile name in its header; the pairing screen says
*"as <profile>"* beside the avatar (`J65` §3). Deleting is behind a typed confirmation and destroys
the directory and the identity secret; the last profile cannot be deleted.

Verified live: migration kept every contact; a new profile shows a different pairing code (its own
key) and an empty list; switching back restores everything; delete returns to "Me". Not yet: photo
avatars, the self card travelling inside the handshake (`J65` §2 — a protocol change), per-profile
exposure (Phase 6). iOS mirror follows.

##### J81. iOS app lock: Face ID was never offered, and the Unlock button was unreadable. — *Don*

Don: *"ios lock screen presents for code but doesn't do face id and has no option for that... when
you dismiss the codes it shows a small button that says unlock but contrast very poor and kind of
pink."* Two causes. `NSFaceIDUsageDescription` was missing from Info.plist, and iOS's answer to
that is to downgrade `deviceOwnerAuthentication` to passcode silently — no error, no Face ID prompt.
Added (Info.plist and `project.yml`). The button used `ChannelColor.primary`, which in dark mode is
the pale pink of the inverted palette; on the black lock screen that is the worst contrast in the
app. Both platforms' lock screens now use the fixed garnet with white text, the one surface that
deliberately does not follow the theme (`ChannelColor.brandBarContainer`).

##### J65e. Profiles on iOS: the same shape, as a `ProfileSession`.

`ProfilesStore` (profiles.json above the per-profile directories; legacy identity and store become
"Me", moved not copied) and a `ProfileSession` that owns one profile's identity store, message store,
transports, handshake and reachability monitor. Switching the active profile stops the old session
and builds a new one; the root view is `.id`'d on the profile so every screen's state resets, and
the environment carries the new session's objects. Each profile's identity is its own Keychain item
(`secret:<id>`); deleting a profile removes its directory and Keychain item. Avatar top-left on
Messages, Contacts and Settings; Settings' bar names the profile; the pairing title reads
*"as <profile>"* under an avatar. Builds clean; unit suite run after.

##### J65f. Chats stay with their profile; badges for mail in other profiles; an optional merged view. — *Don*

Don: *"have to make sure messages and chats stay with the profile and maybe have to have badges on
the profile avatar to show messages arrived and when you open that profile menu... you'd get badges
on each profile that had new messages. There should also be a setting config to choose between
merging all the chats on one screen from all profiles with the profile menu choosing who sends a
message vs separated (default) where each profile's messages stay on screen only when its profile
avatar has been selected."*

**Consequence for the architecture:** a badge for a profile you are not looking at means that
profile must still be collecting. So every profile keeps a live session — its own store and its
internet polling — and switching only changes which session the screens are bound to. Bluetooth and
local-network radios stay bound to the active profile only (one radio, one set of contacts to
answer as); the relay is what carries mail to a profile nobody is looking at. Cost is what `J65b`
warned about: mailboxes polled = contacts across all profiles, per check.

- **Avatar badge**: unread count summed over the *other* profiles. **Picker rows**: each profile's
  own unread count.
- **Separated (default)**: each profile's conversations appear only while it is selected.
- **Merged** (Settings → "Show all profiles' chats together"): one list of every profile's
  conversations, each row carrying its profile's avatar; opening one uses *that* profile's identity
  and keys, because a conversation belongs to exactly one pairing and cannot be answered as anyone
  else. The selected profile is who starts a *new* chat.

##### J82. The iOS screen-capture shield froze the app; and the bar layout Don asked for. — *Don*

**Freeze.** Don, on 2.007: *"everything is frozen. None of the controls do anything."* Cause: `J70b`'s
iOS shield hosted the whole interface *inside* a `UITextField` whose `isUserInteractionEnabled` was
false. UIKit stops hit-testing at a disabled superview, so every control in the app went dead. The
simulator never showed it because I couldn't drive its keyboard that night and only checked that
the screen rendered. Fix: lift the field's secure canvas out of the field and host the content in
it — the compositor blanks the canvas *layer* wherever it lives, and touches pass. Verified on the
simulator by tapping a tab. Shipped as 2.008 (14) within minutes of the report. Lesson recorded in
plain words: **a render check is not a touch check.** Every future build gets a tap test.

**Bar layout.** Don: *"in most apps the profile avatar would be in the upper right-hand corner, so
whatever was in that right-hand corner needs to move to the left... make the top bar a little less
tall... make the avatar icon a little bigger... it should fill most of the space vertically."*
Done on both platforms: avatar top-right, sized to the bar; New Message (Messages) and Add
(Contacts) move to the left; the version line leaves the Messages bar for the bottom of Settings.

**The wordmark as the title.** Don asked whether the app title should look the way it does on the
logo. Yes — the same treatment the website's wordmark uses (uppercase, wide letter-spacing) rather
than an image, so it scales, stays a real accessible title, and ties the app to the logo on sight.

##### J83. The contact page is where a person is created, found, and scoped to profiles. — *Don*

Don, 4 Sep 2026, in four messages. The whole thing, condensed:

1. **The `+` belongs on Contacts only, and it opens "New contact", not pairing.** Fields first;
   pairing is a button at the bottom of the saved card, because *"most users will not go there."*
2. **Find me by.** Beside each phone number and email on the card, a checkbox that opts that item in
   to being searchable by others (`J68` discovery). **Default off.** One line of explanation. The
   checkboxes line up in a column with a short header. Candidates, chosen for translatability:
   *Find me by* (chosen — two common words, no jargon); *Advertise* (too long, and "advertise" has a
   commercial sense in most languages); *Searchable* (adjective, awkward as a column head).
3. **Profile scope on every contact**: a toggle, *All profiles / This profile*, default this
   profile. When a profile is selected, Contacts shows only the contacts scoped to it or to all.
4. **"This is me" is required at first launch.** The self card is the one contact every install
   has; the first-run flow shows it so the user sees the settings on it whether or not they change
   anything. Nobody can search for a person by name, so the name is friendliness, not exposure. The
   documentation encourages the first profile to be the real you; it cannot be required, because the
   app does not know who anyone is.
5. **The selected profile persists across launches and kills** — already true (`profiles.json`),
   confirmed on the emulator.

Don's framing, which is the standard for all of it: *"These sorts of things are just the intuitive
nature of how the app should work, so it'll all seem natural."*

##### J83a. The self card is a normal contact that carries the user's name, marked "This is me". — *Don*

Don: *"the me profile should also be a normal contact because that contact could include email
addresses and telephone numbers and perhaps a handle... to let the user choose how this profile will
be exposed to the world... I wouldn't call that contact me because it should display the user's
name but with a checkbox on that profile which could be offered for any contact; if that check box
says this is me, that becomes the first profile by default, and if somebody else gets selected,
whatever was selected before gets unselected."*

So: the self card is an ordinary contact card (name, phones, emails, later a handle) with the
**Find me by** column (`J83`) — that is exactly where a person decides how this profile is exposed.
A **This is me** checkbox on any contact card marks it as the profile's own card; checking it on
another card unchecks the previous one. "Note to Self" stays as the name of the *thread* to yourself;
the *contact* shows the user's name. The first profile's card is what the first-run flow asks for.

##### J83b. Settings follow the profile, with a Scope switch at the top of Settings. — *Don*

Don: *"the settings should follow the profiles, because different profiles might be for different
security needs... an option to choose whether the settings page applies to all profiles or applies
only to the selected profile, that should probably be one of the first settings on the setting page
— call it Scope."* So: **Scope: All profiles / This profile**, first row of Settings. Under "This
profile" the page edits an override stored with that profile; under "All profiles" it edits the
shared set. A profile with no override inherits the shared set.

##### J65g. One protocol engine per profile — the shared one silently swapped identities.

`MeshEngine` (and the FFI on iOS) holds one identity and one node; restoring another profile's
identity frees the first. With one engine shared across sessions, every profile's seal, open and
token derivation ran as whichever profile had been restored last. Found on the emulator: a message
for the inactive profile was collected at launch (when it happened to be the last restored) and
never after a switch. Each `ProfileSession` now owns its own engine; the screens use the session's.

##### J83c. Which settings follow the profile, and which are a vote. — *Don*

Don: *"if the settings only follow the profile, then the settings would change when you change
profiles... if the background profile called for pulling messages at X time interval and another
profile called for pulling more frequently, the more frequent would win... if one profile enabled
Internet and another one did not, then that would just mean the one that did not, did not receive
things by Internet... think about which settings really can't apply to the profile, and which ones
would be more like a vote."*

| Setting | Scope | How several profiles combine |
|---|---|---|
| Internet (relay) | per profile | gates only that profile's mailbox polling |
| Check cadence, quiet hours | per profile | **vote: the most frequent wins** for the device's wake schedule; each profile's own loop still honours its own |
| Read receipts, photos direct-only, notification sound | per profile | no combining needed — each applies to its own conversations |
| Bluetooth, local network | device (one radio) | **vote: any profile on turns the radio on**; only the selected profile answers on it |
| Relay for others | device | vote: any |
| Block screenshots, app lock, merged view, Scope itself | device | one window, one lock, one list |

The **Scope** switch (`J83b`) decides which set the Settings page is editing; the table decides how
the device behaves when profiles disagree.

##### J83d. Avatars: stock, photo, or camera; initials when none. — *Don*

Don: *"the avatar image needs to allow you to either pick some stock avatars... or select a
photograph from your device or take a picture... same as every other app that has profile pictures
and if no avatar or photo is selected, it shows the initials of the person, in my case for Don Elton
it would just be DE."* Initials are what both platforms draw today (first letter of the first two
words). Stock set, photo library and camera are the next step for the profile card and the contact
card alike; the image is stored with the profile, never sent anywhere except inside the sealed
self card (`J65` §2) once that exists. Recorded so both platforms get it in the same shape.

##### J84. Letters only, closer together; the thread title centred with an avatar; avatars travel in the payload. — *Don*

Don: *"the status lights at the top of the screen on the Android show B and I illuminated while on
the iPhone it only shows I... the plan was not to have dots and letters, but to just have colored
letters versus grey letters so they can be much closer together... they show up right underneath
the name of the person with whom you're chatting so that name should probably be centered and show
an avatar if there is one to the left of the name... those avatars would be exchanged during the
pairing or after receiving the first text message from that user, the avatar would be part of the
payload and once acknowledged would not be included in the payload again unless it had changed on
the other phone... if I'm on WhatsApp and somebody changes their profile picture, I see it pretty
much as soon as they change it."*

1. **Letters only** (`J37` said so; both platforms still draw a dot beside the letter). Coloured
   when lit, grey when not, tight spacing. Both platforms.
2. **B lit on Android, not on iOS**, same room: to verify on real phones — the emulators bridge
   Bluetooth on the host, so their B means little. If it holds on hardware, iOS's BLE transport is
   not seeing the peer and that is a bug, not a display difference.
3. **Thread title centred, avatar to its left** — the contact's picture when one exists, initials
   otherwise.
4. **Avatar exchange** (`J65` §2's self card, made concrete): the card — name, picture — rides
   inside the sealed payload as a `payloadType` of its own, sent once after pairing (or with the
   first message), acknowledged, then re-sent only when it changes (a hash of the card lets the
   sender know). A change lands with the next message or, if the contact is idle, as a card-only
   payload — which is how WhatsApp's picture appears "pretty much as soon as they change it".
   Protocol work: a new payload type in the Rust core, both FFIs, both stores. Queued.

##### J85. Radio sends are backed by the relay. — *from Don's phones, 4 Sep*

Don, on real phones after pairing: *"none of the messages arrived in either direction... the Android
shows a blue dot in the letter B."* The send path chose the most private rung whose scan saw the
peer and stopped there. A Bluetooth "send" that merely queues to a scanned address counts as sent,
so the message went SENT and nothing ever reached the relay the other phone was actually reading.
Now: after a send on Bluetooth or the local network, the same envelope is also deposited to the
relay whenever the Internet rung is on. The receiver de-duplicates by wire id, so the worst case is
one redundant deposit; the best case is that a message reaches a phone that could not hear the
radio. The privacy cost — the relay sees a deposit that might have stayed local — is real and small,
and can become a switch ("nearby only") later. Both platforms.

##### J86. Calls are a hand-off: which number, to which app, chosen per contact and per user. — *Don*

Don: *"make room for those calling buttons for a video call or audio call that we will pass off...
When you create a contact you would have to designate which of their phone numbers was the one to
which their WhatsApp account was attached because it is not necessarily the same number that the
other user might expose for texting within our app... a check box for whichever apps we allow hand
off for... WA with the correct color, FaceTime FT... another section in the settings where the user
could designate what his preferred hand-off apps are and when he hits the telephone icon or the
video call icon, if there was more than one option selected, he would choose to which app the hand
off occurred and hopefully there's a way to pass the phone number."*

Design:
- **Thread header**: audio and video buttons on the right (the space the WhatsApp header uses).
- **Contact card**: each phone number and email carries small app marks (WA, FT, …) the user ticks
  to say "this is the number that app knows". Default none.
- **Settings → Calls**: which hand-off apps this user has. Tap a call button: one app ticked on
  both sides → straight hand-off; several → a chooser; none → the button explains in one line.
- **Passing the number** is what makes or breaks this. From inside an app it is done with URL
  schemes / intents, not the web: `tel:`, `facetime://`, `facetime-audio://`, `whatsapp://send?
  phone=` (and `https://wa.me/`), Signal's `sgnl://`, Telegram's `tg://resolve?phone=`. iOS requires
  each scheme to be declared in `LSApplicationQueriesSchemes` before the app may even ask whether
  the other app is installed; Android uses `ACTION_VIEW` on the same URLs (and package intents).
  **What is not guaranteed:** that the target app *starts a call* rather than opening the chat —
  WhatsApp's public scheme opens the conversation; the call button is one tap further, and any
  "direct call" scheme is unofficial and can change. Say so in the UI rather than promise.
- **Research to do first** (Grok, live search): the current list of apps with a documented
  number-carrying scheme on each platform, and which of them accept a call intent. Phase 5.

##### J86a. Hand-off research: what each app's scheme actually does with a number. — *Grok (live search), 4 Sep 2026*

Findings, per platform, for the apps `J86` names. The rule that came out of them: **the card carries
marks only for apps that can take a number at all**, and the button says "Open in …" rather than
"Call", because most of these open the chat and the call is one more tap.

| App | Takes a number | What happens | Notes |
|---|---|---|---|
| Phone dialer | `tel:` (both) | Dials after the OS asks (iOS) / opens the dialer filled in (Android `ACTION_DIAL`) | No permission needed for `ACTION_DIAL`. |
| WhatsApp | `https://wa.me/<digits>` (both), `whatsapp://send?phone=` (iOS) | Opens the **chat**; call button is one tap further | No official direct-call URL; unofficial ones break. |
| FaceTime | `facetime://<number or email>`, `facetime-audio://` | **Starts the call** (iOS only) | Not on Android; the FT mark is hidden there. |
| Signal | `https://signal.me/#p/+E164` (`sgnl://` on iOS) | Opens the chat | Fragment, so the number never reaches Signal's server. |
| Telegram | `https://t.me/+E164`, `tg://resolve?phone=` | Opens the chat, subject to their privacy setting | |
| Google Meet | meeting codes only | — | No documented number-carrying 1:1 scheme any more; **not offered**. |
| Zoom | `zoomphonecall://` (Zoom Phone licence only) | Dials | Consumer Zoom is meetings-only; **not offered**. |
| Skype / Teams | `skype:+E164?call`, Teams deep link | Dials | Niche; not offered in the first cut. |

Platform requirements: iOS must list every scheme it will *ask about* in `LSApplicationQueriesSchemes`
(`whatsapp`, `sgnl`, `tg`, `facetime`, `facetime-audio`); opening without asking needs nothing.
Android 11+ must declare the packages or intents it queries in `<queries>` (`com.whatsapp`,
`org.thoughtcrime.securesms`, `org.telegram.messenger`, plus an `ACTION_DIAL`/`tel:` intent so the
default dialer is found without naming OEM packages). Numbers are passed as E.164; the app strips
everything but digits and keeps the leading `+`.

##### J83e. The contact page, as built. — *Claude, 4 Sep 2026, from Don's list*

- `Contact` grew `phones`/`emails` (lists of `ContactField { value, findMeBy, apps }`), `scope`
  (All profiles / This profile), and `avatar` (stock glyph or a JPEG in the profile's `avatars/`).
  The single `phone`/`email` fold into the lists at load and are then null (`J72a`: additive).
- **New contact** and **Edit** are one form; save lands on the card, and the card carries pairing at
  the bottom. The `+` is on Contacts only and opens the form. The Chats bar lost its Add button.
- **Find me by** is a column on *your* card only; on someone else's card that column holds the
  `J86` app marks instead. Reason: "find me by" is a fact about you; the marks are a fact about them.
- **This is me** is a checkbox on the form. Ticking it on another (unpaired) card moves its fields
  onto the one self card and removes the other row -- exclusive by construction, no second flag to
  keep in step. Hidden on paired cards, since a pairing is somebody else's identity.
- The self card shows the user's own name; first launch asks for it once (Skip allowed). The
  subtitle on that row is "This is me".
- **All profiles** cards from other profiles appear in every profile's list with an "In <profile>"
  subtitle and open in their own profile (`?profile=` on the route), the way `J65f`'s merged chat
  list already does. The card is never copied between stores.
- Avatars: twelve stock glyphs shared by both platforms, the system photo picker, or the camera;
  pictures are square-cropped to 256 px JPEG and live beside the store. Initials otherwise ("DE").

##### J84a. The contact card travels as its own payload type. — *Claude, 4 Sep 2026*

`PROTOCOL.md` §5.7: `payloadType 7`, body JSON `{v, name, stock?, jpeg?}`, the JPEG 128 px and at
most 16 KiB so the whole thing fits the largest padding bucket. Sent to every paired contact after a
pairing completes and again whenever the self card changes; each contact row records the hash of
the card last sent to it, and a send that found no open path is retried at the next launch. On
arrival it is never filed as a message: the receiver keeps `peerName` and `peerAvatar` beside its
own choices, and the picture shows only where the user has not picked one -- the local name always
wins (`Contact.displayName` stays local, as it always was). Verified Android to Android on the
emulators: Alice's stock face reached Bob's list within a minute of both launching.

##### J88. Disappearing messages and the picture progress bar, as built. — *Claude, 4 Sep 2026, from `J70` and the Phase 4 list*

- **The timer is per conversation, and the stricter side wins.** Each phone keeps its own choice
  (Off / 1 hour / 1 day / 1 week) and the last value the peer declared; the effective timer is the
  lower non-zero of the two, exactly `PROTOCOL.md` §5.5 key 5's rule. A change is carried to the
  peer as a `payloadType 4` policy update with body `{"v":1,"disappearAfter":<seconds>}` -- the
  FFI does not yet expose the policy submap, so the update is its own small payload rather than a
  piggyback. Convergence is lazy, as §5.5 says: new messages only, and the dialog says so.
- **Each phone enforces on its own copy.** A message is stamped `expiresAt` when composed or when
  it arrives; a sweeper deletes past-due messages and their picture files at load, at start, and
  every thirty seconds while the app is up, scrubbing the old document (`save(scrubPrevious)`) so
  a disappeared message is gone from the disk as well as the screen. `J70`'s limit is on the
  dialog in one line: they may still photograph the screen.
- **The bar** under an outbound picture counts slices a path has taken over slices that exist:
  blue while sending, green once every slice is out, red on failure with **Resend** (back to
  queued, bar reset, delivery re-attempted) and **Cancel** (delete). Text has no bar; it is one
  envelope. A slice counted is a slice a rung accepted, not one the peer acknowledged -- the
  receipt is still the only end-to-end signal, and the tick beside the time still means that.

##### J89. View-once pictures, as built. — *Claude, 4 Sep 2026, from `J70`*

- **Announced by hash, not by a new header field.** The sender puts a policy update
  `{"v":1,"viewOnce":"<SHA-256 of the picture bytes, base64>"}` ahead of the slices; when the
  receiver's assembler completes a picture whose hash is on that short list, the picture is
  view-once. The assembler does not expose the attachment id across the FFI, and the bytes are
  the one thing both sides can hash independently -- so no native rebuild and no wire change.
- **Never written decrypted (`J70`).** Both phones store the file under a fresh AES-256-GCM key
  that lives only in the message row; the sender's copy is decrypted in memory to seal the slices
  and the file and key are deleted the moment the message is SENT -- the sender does not get to
  look again, WhatsApp's rule. The receiver's row is a chip, "tap to view once"; the viewer shows
  the picture full-screen from memory, and closing it deletes the file and the key and leaves a
  chip that says "Opened". The document is scrubbed on the way out so the key is not left in the
  old file.
- **Capture** is already blocked app-wide on both platforms (`J70b`); `J70`'s limit -- another
  camera -- stands and is stated once, on the disappearing-messages dialog, not repeated here.

##### J90. Direct Wi-Fi, as built -- and what is not yet known about it. — *Claude, 4 Sep 2026, from `J21` rank 1*

- **iOS: MultipeerConnectivity over AWDL** (`DirectWiFiTransport.swift`), service type
  `channel-mesh`, a random peer name per launch, both phones browse and advertise, the lower name
  invites. **Android: Wi-Fi Aware** (`DirectWifiTransport.kt`): publish and subscribe under the same
  name, one "hi" / "port:n" exchange, a data path, an ordinary socket over it. Wi-Fi Direct was
  passed over because it needs a system dialog per connection.
- **Everything above the link is the LAN rung's**: the same frames, the same handshake first, the
  same `LanPeerAuthenticator` -- so a stranger's Channel phone learns that a Channel phone is near
  and nothing else, and no envelope moves until a sealed blob proves a contact.
- **Same platform only**, as `J21`'s table says: iPhone to iPhone, Android to Android. The rung sits
  at rank 1 in the ladder and lights `W`; it is on by default (it touches nobody's infrastructure)
  and switchable under Ways to reach you. Android asks for `NEARBY_WIFI_DEVICES` (13+) or fine
  location only on phones with the Aware radio; phones without it never light `W`.
- **Untested on hardware.** The emulators have no Aware radio and the simulator has no AWDL, so
  both sides are verified by build only. `J21`'s own caveat stands: whether a peer link degrades the
  infrastructure connection is chipset-dependent and "believed, not measured". First thing to try on
  two real phones of the same kind: airplane mode with Wi-Fi on, send a photo, watch for `W`.

##### J64a. The beacon, and the flights over the radio, as built. — *Claude, 4 Sep 2026*

`J64`'s beacon is advertised exactly as specified: `SHA256("channel/pair-beacon-v1" ‖ key ‖ nonce)
[0..16]` worn as a service UUID while the code is on screen, and only then. Android carries it in
the scan response (a second 128-bit UUID does not fit beside the first); iOS advertises it as a
second UUID, which lands in the overflow area that other iPhones read in the foreground. A scanner
that has just read a code computes the same UUID and looks for the phone wearing it.

**And the flights now travel over Bluetooth as well as the relay.** `J63` already said the offer
"can be shouted into the room over BLE" because it is legible to exactly one phone; so it is. A
pairing flight on the radio is a small frame -- `"CHPR" ‖ slot token (16 bytes) ‖ payload` -- the
Bluetooth transport recognises by its magic and hands to whoever is pairing rather than to the
message store. The scanner sends flight 1 to the beacon's phone if it has seen it, else to every
Channel phone in range, and deposits it on the relay too; the displayer answers on the path the
flight came by (and the relay); whichever path finishes first wins. **With no internet at all, the
radio is the only path, and pairing still completes.** Verified on the emulators that the relay
path is unchanged (a full pairing with confirmation digits both sides); the radio path needs two
real phones, like `J90`.

##### J91. Phase 6 as a plan: write-only discovery by number or email, and what it needs from Don before a line is written. — *Claude, 4 Sep 2026, proposal*

Everything above Phase 6 on the list is built. Phase 6 is the one block that cannot be started
autonomously, because it spends money and changes a public promise. This entry is the plan, so the
decisions can be made in the morning and the build can begin the same day.

**What gets built (from `J68` (c), `J68a`, `J68d`, `J65a`, `J65b`):**

1. **A discovery slot per verified value.** `slot = SHA256("channel/discover-v1" ‖ E.164 or
   lowercased email)[0..16]`, a plain relay token like any other. Anyone may `deposit` an
   **invitation** there; nobody can read whether the slot has an owner (the server's `collect`
   already needs the token and returns padded responses either way). The owner's profile polls its
   slots beside its contact tokens. No new server endpoint is needed for this part.
2. **The invitation** is a new payload the receiver can open without a shared key: the sender's
   identity key, its contact card (`J84a`'s JSON), and a PAIR_OFFER, all under a fresh ephemeral
   key sealed to nothing -- trust on first use, as `J68` §5 says it must be. The receiver's reply
   completes the same handshake the QR path uses, so a contact made by number ends in the same
   `ContactKeys` and starts as **Not verified**, which the tiers already render.
3. **Proof of control** (`J68a`): a one-time code by SMS or email, checked by a new server
   function that never stores the value -- it hands back a short-lived signed proof the app keeps
   against the value (`proofs[value]`), voided the moment the field is edited. Device-wide, so a
   second profile reuses it; exposure is per profile and at most one profile per value (`J65a`).
4. **The request mailbox** (`J68d`): inbound invitations land in a holding list, default "request"
   (never "blocked"), with **Accept**, **Talk for now** (a temporary chat that lapses in 30 days
   unless promoted), and **Block**. Ships in the same release as 1–3 or none of them ships.
5. **Find me by** ticks (`J83a`) become the publication switch: a ticked, verified value is polled;
   unticked or unverified is inert. Unpublishing keeps draining the old slot for the relay TTL
   (`J65b` rule 2) so nothing in flight is orphaned.

**What it needs from Don, in order:**

- **An SMS provider and a budget.** *Decided 4 Sep, Don: "set budget to $200/m for now for texts" -- SMS
  verification is in, capped at $200 a month (about 20,000-40,000 US texts); email verification is
  free and also in.* Telnyx is the one named in the plan; verification SMS runs
  about half a cent to a cent each in the US and more abroad. Email verification can go through
  Netlify's own email integration or Resend at effectively no cost. Say which, and whether SMS is
  worth it at all for v1 (email-only discovery is cheaper and still covers the hotel case).
- **The copy change first.** `channelmessenger.net` and both store listings say "No phone number
  and no email address, at any point." Before anything above ships that sentence becomes:
  *"No phone number or email is required. If you choose to be findable by one, it is verified
  once, never stored beside your identity, and you can switch it off."* English governs; the six
  translations follow. This is a one-line site change and a store-listing edit; it is the gate.
- **A yes to trust-on-first-use** for contact by number, stated plainly in the app as "Not
  verified" until the two people compare numbers -- the same exposure WhatsApp and Signal carry.

Estimated build once decided: two days for the app halves on both platforms, one for the server
function and the site, one for testing on real phones. No handles in v1 (`J68c` addendum).

##### J92. Settings are Profile or Global, the box at the top says which, and Relay is global. — *Don*

Don, 4 Sep: *"Relay is a type of setting that should be global... it has two actions -- it allows a
user to take advantage of relays but also presents the user's phone as a potential relay for others...
top heading says Profile Settings vs Global Settings and if top is Profile Settings the bottom
heading appears above the Global Settings."*

As built, both platforms:
- A box at the top of Settings reads **Profile Settings** (with the profile's name) or **Global
  Settings**; tapping it flips. That replaces the "All profiles / This profile" segmented row.
- Under **Profile Settings** the profile's own settings come first (Ways to reach you, Checking,
  Privacy, Calls), then a thin line and a **Global Settings** heading over the settings a phone has
  only one of: **Relay**, **Notifications**, **Screen & lock** (screenshots, unlock, merged chats).
  Under **Global Settings** there is one list and no line, and everything on it writes the global
  values.
- **Relay is global**, no longer a vote: a phone either is a relay or is not. Bluetooth, direct
  Wi-Fi and local network stay votes (any profile that wants a radio gets it); Internet, receipts,
  photos and calls stay per profile.

##### J93. The fifth letter, R. — *Don*

Don: *"add another connection letter R for relay which means we are being a relay and this light
only shows at all if relay is enabled in settings."* Built: **R** sits after W B L I in the thread
header. Hidden unless Relay is on; dim when on but no radio peer is near; lit when a radio peer is
near enough to carry for. It is a statement about this phone, not the contact, so it is not a
`MessageChannel`/`ChannelLight` and never appears as a per-message mark.

##### J94. Pairing wins; discovery is for the unpaired; one profile per presented value. — *Don*

Don, 4 Sep: *"once verified by pairing the connection will normally connect and auth based on the
pairing even if a phone number is available for that destination... messages are sent from a
profile to a profile really so a verify only applies to one profile and ideally no two profiles
on the same phone should present the same email or same number or same handle as there's no way
to know how to route the conversation... a profile can have several ways to be reached but two
profiles can't share an incoming connect channel."*

Three rules, the first two for Phase 6 and the third built now:
1. **A pairing always wins.** Where `ContactKeys` exist, every message is sealed to them and
   discovery is never consulted, whatever numbers the card carries.
2. **Discovery is only for a contact with no pairing**, and what it produces starts as Not
   verified (`J68a`).
3. **No two profiles on one phone present the same value.** `J65a` said it; the contact editor now
   enforces it: ticking **Find me by** on a number or email another profile already presents is
   refused with one line naming that profile. Numbers compare by digits (leading `+` kept), emails
   lowercased. A profile may still present several values of its own.

Email verification is free (Resend's free tier or Netlify's email integration); only SMS costs.

##### J95. Verification: international from day one, email free, SMS capped. — *Don*

Don, 4 Sep: *"what about international as we gonna need that of course"* and *"email verify should
be free basically... set budget to $200/m for now for texts."*

**International is not an extra; it is the default.** The verify endpoint takes E.164 only -- a
leading `+` and 8 to 15 digits -- so a UK, Spanish or Australian number is the same shape as a US
one and no code branches on country. Consequences that follow, and the honest costs:

| | US / Canada | Most of Europe, LATAM, APAC | The expensive tail |
|---|---|---|---|
| Telnyx SMS, per message | about $0.004-$0.01 | about $0.01-$0.06 | India, Nigeria, some Gulf carriers: $0.06-$0.20+ |
| What $200 a month buys | 20,000-50,000 | 3,000-20,000 | as few as 1,000 |

So the cap is counted in **messages, not dollars** (`VERIFY_SMS_MONTHLY_CAP`, default 20,000),
because a dollar cap would be spent unpredictably fast by whichever countries turned up. Two
guards go with it: five starts per value per hour, and the ability to refuse SMS while still
offering email when the cap is reached.

**Email is free and is the recommended path everywhere.** Resend's free tier (3,000 a month) or
Netlify's own email integration; no per-message cost, no country pricing, no carrier filtering,
and no A2P registration. The app should offer email first and SMS second.

**Two things about SMS abroad that must not surprise us later:** a US 10DLC or toll-free sender
cannot reliably deliver to many countries, so international traffic needs either an alphanumeric
sender ID (fine in most of Europe, forbidden in the US) or local numbers; and some countries
(India, Vietnam, Saudi) require template pre-registration. Start with email everywhere, SMS in
the US and Canada, and add countries as testers appear in them.

##### J96. A verification is a fact about two profiles, it is clearable, and it can never transfer. — *Don*

Don, 4 Sep: *"verification is really something that exists between two users and isn't a trait for
a single person... when I say two persons I really mean two profiles... could the verify transfer
in some way if we could know the 2nd profile was the same user?"*

**The first half is exactly how it is modelled, and now visibly so.** `numbersConfirmed` and the
provenance live on the *contact row*, which belongs to one profile's store. Two profiles on this
phone that both know the same person have two rows and two independent answers. Nothing about
verification is stored against a person or a device.

**It is clearable, and that is deliberate rather than a testing convenience.** iOS had *Clear
verification*; Android now has the whole identity block -- the safety number, **These numbers
match**, and **Clear verification**. A user may withdraw a claim they made: they are no longer
sure, the peer changed phones, or they are testing. The badge derives from the flag, so clearing
it reads as Not verified immediately.

**The second half is a no, and the reason is the reason profiles exist.** A verification cannot
transfer from one profile to another, even between two profiles of the same person, because:

1. **The safety number is derived from the identity keys**, and `J65` gives every profile its own
   keypair. Having compared the number for profile A tells you nothing whatever about profile B's
   key -- the comparison would simply not have been done.
2. **Transferring it would be the linkage the feature exists to prevent.** If verifying Don-real
   also verified Don-pseudonym, the app would have told the verifier those two are one person.
   That is precisely the inference `J65` refuses to let anyone draw, and it would be worse coming
   from us than from a shared key, because it would arrive as a green badge the user trusts.
3. **A second profile on a destination phone is a different correspondent**, and treating it as
   the same one would mean messages sealed under a key the user never checked.

So: verify per profile pair, always. What *is* device-wide is the other kind of proof --
control of a phone number or an email (`J65a`, `J91`) -- because that one leaks nothing about
which profiles exist.

##### J97. A profile can carry its own lock. — *Don*

Don: *"can we perhaps make a profile private with its own security block... you cannot switch to a
faceid protected profile without the auth step. that's super more secure and will be appreciated."*

Built on both platforms:
- **Require unlock to open this profile**, under Privacy, and only offered while Settings are in
  **Profile** scope -- it is a property of one profile and would be meaningless on the device set.
- The profile picker shows a padlock beside a locked profile, and **switching into it prompts**
  for face, fingerprint or passcode. Cancelling leaves the picker where it was.
- **The app also opens locked** when the profile it opens into is locked, so a locked profile is
  not readable simply because it was the last one open.
- On a phone with no biometric and no device credential the prompt succeeds immediately, on
  purpose: a lock nobody can open would strand a profile with no way back to it. The switch is
  disabled there and says why.

This is independent of the app-wide lock (`Screen & lock`, global): the app may open freely while
one profile stays behind a face.

##### J98. One profile on several devices: link by QR, one keypair **per device**, and choose per profile which devices carry it. — *Don, and a recommendation*

Don, 4 Sep: *"what about allowing a profile to live on more than one device... require it to be set
up in person with another qr code the way WhatsApp auths adding a desktop app. or should it be per
device instead where all the profiles show on the new device? maybe make it an option as to what
gets shared on the other device."*

**Recommendation: per profile, per device, chosen -- and each device gets its own keypair.** Not
built; this is its own phase. The reasoning, because the obvious design is the wrong one:

#### Why the two devices must not share one keypair

The tempting version is to copy the profile's private key to the second device. It breaks on our
own architecture in two places:

1. **Collection is destructive.** A destination token addresses one mailbox, and collecting
   confirms and deletes. Two devices deriving the same token would race: whichever polled first
   would take the message and the other would never see it, silently. That is the single worst
   failure this product can produce (`J65b` says so about a much smaller version of it).
2. **One key, one safety number, one compromise.** A stolen laptop would hold the same identity
   the phone has, and revoking it would mean revoking the person. Signal solved this years ago by
   giving every linked device its own keys, and it is the right answer here for the same reason.

#### The shape that works

- **Linking is the pairing ceremony we already have, pointed inward.** The new device shows a code;
  the phone scans it; they run the same three-flight handshake (`J40`), so the link is
  key-agreement strong and needs no server. In person, as Don says.
- **The new device mints its own identity keypair.** It is a second device of the same *profile*,
  not a copy of it.
- **Contacts learn about it the way they learn a contact card** (`J84a`): the profile sends its
  paired contacts a small "these are my devices" payload listing device public keys, re-sent when
  it changes. A sender then seals one envelope per device and deposits under each device's own
  token. Each device therefore has its own mailbox and there is no race.
- **Per profile, per device.** Because enrolment is per profile, "which of my profiles live on the
  iPad" is a list of checkboxes on the phone, and the answer can differ per device. That is the
  flexibility Don wants, and it costs nothing extra to model.

#### The costs, stated before anyone builds it

- **Every message becomes N envelopes** for a contact with N devices, and each device polls its own
  tokens. `UNIT-ECONOMICS.md` counts polling as the dominant cost, so devices multiply it exactly
  as profiles do; a cap (say four devices per profile) belongs in the first version.
- **History does not follow.** A newly linked device starts empty unless we build a transfer, and a
  transfer of old messages is a second feature with its own risks. Say so plainly in the UI rather
  than syncing silently.
- **Removing a device must be immediate and visible**: dropping it from the device list stops
  senders addressing it, and the contact's app should say a device was removed rather than
  quietly changing behaviour.
- **A Mac or iPad build does not exist yet.** The first useful pair is two phones, which is also
  the easiest to test.

#### What it is not

It is not `J20`'s "each device is its own identity" being reversed -- that stays true at the key
level, which is the point. What changes is that several identities may present as one *person* to
a contact, with the contact's app doing the fan-out.

##### J99. Platform order: phones first, then linked devices, then tablets properly, then macOS, then Windows. — *Don, and the measurement behind the answer*

Don, 4 Sep: *"i'm thinking the phones should be nearly feature complete before we expand to create
the macos and windows desktop apps unless you think that's easier than I do... and we need ipad and
android tablet support as well if we don't already have it."*

**Agreed on phones first, and there is a hard dependency that settles the order anyway.**

#### Where tablets actually stand today, measured rather than assumed

| | Ships to it | Runs | Looks designed for it |
|---|---|---|---|
| iPad | yes -- `TARGETED_DEVICE_FAMILY "1,2"`, all four orientations | **yes, verified 4 Sep on an iPad Pro 11-inch simulator** | **no** |
| Android tablet / Chrome OS | yes -- the Play track already lists Phones, Tablets, Chrome OS, Android XR | expected (no tablet-only code), untested | no |

So tablet *support* exists and nobody has to do anything to keep it. What does not exist is a tablet
*layout*: on the iPad the app is a phone stretched wide -- one narrow column of content with grey
either side, the tab bar promoted to a segmented control at the top, and the wordmark pushed onto
its own row. Usable, not embarrassing, not right.

#### Why the desktop cannot come first even if we wanted it to

**A desktop app is blocked on `J98`.** Without linked devices, a Mac build would be a *separate
identity* -- a second contact your friends would have to pair with, with its own messages, which is
not what anyone means by "the Mac app". Linked devices is therefore not an optional companion to
desktop, it is the thing that makes desktop mean anything.

#### The order, and what each step costs

1. **Phones feature-complete** (where we nearly are; discovery, `J91`, is the gap).
2. **Linked devices** (`J98`) -- the prerequisite for everything below.
3. **Tablet layout**: one `NavigationSplitView` on iOS and a list-detail pane on Android, behind a
   width check. This is worth doing before any desktop work because *it is the same layout a
   desktop needs* -- do it once on a platform we already ship to.

   **And on iPadOS that now means more than a split view** (Don, 4 Sep: *"have to support the new
   features of windowing and mac style menu items too so it looks good on iOS 27"*). iPadOS 26
   brought real windowing and a menu bar to the iPad, and 27 continues it. What that costs us,
   concretely:
   - **Resizable windows, properly.** A window can be any size at any moment, so every screen has
     to be size-class driven rather than assuming a phone width. Our `widthIn(max: 480)` habit
     already helps; the split view has to collapse and expand as the window changes, not at launch.
   - **A menu bar** (`CommandGroup`/`commands { }` in SwiftUI): New Chat, New Contact, Check Now,
     Settings, and the profile switcher belong there, with the keyboard shortcuts a menu implies.
     This is the same code a Mac Catalyst build needs, which is another reason step 3 comes before
     step 4 -- it is not iPad work that gets thrown away.
   - **Multiple windows of our own** (two conversations side by side) is the one piece to defer:
     it needs the store to be safe against two windows on one profile, and it is a want rather
     than a need.
   - **Pointer and keyboard**: hover states, arrow-key navigation of the conversation list, and
     Return to send. Cheap, and conspicuous by its absence on a tablet with a keyboard.

   The honest note for scheduling: this is no longer "make it wide". It is closer to building the
   Mac app, which is exactly why doing it once, here, is the cheap route to both.
4. **macOS**: with step 3 done, Mac Catalyst (or "Designed for iPad" on Apple Silicon, which needs
   nothing at all) is the cheapest real desktop app we will ever get. The Rust core already builds
   for macOS.
5. **Windows**: the expensive one and genuinely last. No shared UI layer with anything we have, a
   new Bluetooth and networking stack to write, and its own store and signing story. The core
   ports; nothing above it does.

#### On keeping them in sync, since Don raised it

It is *harder* than it looks, and the reason is that the shared part is not where the work is. The
Rust core is one implementation both apps already use, and it costs nothing extra per platform. The
UI is duplicated by hand, and `UI-CONSISTENCY.md` exists because two of them already drift -- the
iOS thread header lost its status letters for a day this week without anyone noticing until Don
looked. A third and fourth UI does not add 50% each; it multiplies every future decision by the
number of places it has to be re-made. That is the argument for finishing the phones first, and it
is Don's own instinct.

##### J100. One shape on every platform: the same three panes, the same menus, the same words. — *Don*

Don, 4 Sep: *"we'd like the macos and windows and ipados (and android) to look and feel the same in
that way."*

**Agreed, and the way to get it is to write the shape down once, now, before four UIs exist.**
`UI-CONSISTENCY.md` already does this for the two phone apps and it is the only reason they match;
this is that discipline extended to the big screens. The rule is *the same shape and the same
words*, never the same pixels -- an app that ignores its platform's conventions feels wrong
everywhere rather than right once.

#### The shape, on any screen wide enough

    ┌────────────┬───────────────────────┬──────────────┐
    │ profiles + │  conversation list    │  the thread  │
    │ nav rail   │  (or contacts)        │              │
    └────────────┴───────────────────────┴──────────────┘

- **Three panes** on a wide window: the rail (Messages / Contacts / Settings plus the profile
  avatar), the list, and the thread. Two panes when narrower, one when phone-width -- the same
  code path, driven by width, so a resized iPad window and a small Mac window behave identically.
- **The thread header is the same everywhere**: back (when one-pane), picture, name, the status
  letters beneath, call and video at the right.
- **The garnet bar, the wordmark, the letters W B L I R, the badge words** -- identical, because
  they already are on the phones and they are the app's identity.

#### The menu bar, written once and reused four times

macOS, iPadOS 26+ and Windows all have one. The same commands in the same order, with the same
shortcuts, on all four:

| Menu | Items | Shortcut |
|---|---|---|
| File | New Chat · New Contact · Check Now | ⌘N · ⇧⌘N · ⌘R |
| Edit | the platform's standard editing items | -- |
| Profile | switch profile (list) · Manage Profiles | ⌘1…⌘9 |
| View | Messages · Contacts · Settings | ⌘⌥1…3 |
| Help | How Channel picks a path · Privacy | -- |

Android and iPhone have no menu bar, so those commands live where they already do -- and *nothing*
may exist only in a menu. A menu is a second route to something reachable another way, never the
only route, or the phones lose the feature.

#### What "the same" does not mean

- Not the same widgets: a Mac uses a real toolbar and sidebar, Windows uses its own title bar,
  Android uses Material navigation. Fighting that produces an app that feels foreign on all four.
- Not the same input assumptions: pointer, keyboard, touch and pencil all have to work on the two
  tablets, and Return-to-send belongs anywhere there is a keyboard.
- Not the same file: sharing UI code across SwiftUI and Compose is a bigger project than the four
  apps. What is shared is the Rust core, this document, and `UI-CONSISTENCY.md`.

**Where this is enforced:** `UI-CONSISTENCY.md` gets a section per screen with the three-pane
behaviour and the menu table above, and any new platform starts by implementing that document
rather than by looking at screenshots of the others.

##### J101. Discovery, as built -- and the one thing verification cannot stop. — *Don*

Don, 4 Sep: *"if you check find me by for an email or phone that's when you have to do the verify
for that item before we can make it live for discovery... maybe say unverified and not expose for
discovery until verified"* and *"otherwise someone could accept for a phone they don't own/control
- not good."*

#### Built and tested end to end on the emulators (4 Sep)

1. **The tick is where verification starts.** On your own card an unproven number or address shows
   the word **Unverified** in place of the checkbox; tapping it sends a code, and the tick appears
   only once the value is proven. A proof turns the tick on, because asking for it was the point.
2. **Nothing is published without a proof.** `publishedValues` is "ticked **and** proven", so an
   unverified value is polled by nothing -- `J68a`'s "inert in both directions", enforced in one
   place rather than trusted to the UI.
3. **An invitation is a pairing code left at a slot** (`Discovery`): the sender deposits its own
   `PairingPayload` at `SHA256("channel/discover-v1" ‖ value)[0..16]` and runs the ordinary
   listener; the recipient collects it, and accepting runs the ordinary scanner path. The three
   flights travel on the usual rendezvous slots, so **no new handshake exists** and what comes out
   is a real key agreement.
4. **It lands in the request mailbox, never in the contact list** (`J68d`): Accept or Ignore, and
   the contact that results reads **Not verified**, because nobody compared a number.
5. **A stranger already paired is silently absorbed**: an invitation whose identity key matches an
   existing contact is dropped rather than raised as a request. Observed in testing, and correct.

Verified on two emulators: a phone with no shared history reached `bob@example.com`, the request
appeared on the other phone, accepting completed the key exchange, both sides showed **Not
verified**, and a message crossed and arrived.

#### The limit, stated plainly because it cannot be engineered away here

Verification stops **this app** from publishing a value its user does not control. It does not stop
a *modified* app from polling the slot of a number it does not own: the slot is derived from a
public value, and the relay cannot tell one 32-hex token from another -- by design, because that is
exactly what makes discovery write-only and unenumerable.

Closing that hole would mean the server checking a proof before letting anyone collect a discovery
slot. **That would hand the server a list of which values are being polled** -- a membership
oracle, the single thing `J68` (c) was chosen to avoid. We are not trading an enumerable directory
for a narrow impersonation defence.

What actually limits the damage, and what the UI must therefore keep saying:
- **Everything reached this way is trust on first use** and is labelled *Not verified* until two
  people compare a safety number. An impersonator gets an unverified conversation, which is what
  every unverified conversation looks like.
- **Nothing is delivered to them that was not sent to that value**, and the sender chose to write
  to a number rather than to a person they had met.
- **The request mailbox means an interception is visible**: the real owner sees no request, and the
  sender sees a contact that never verifies.

So: verification is a gate on publishing, not a proof of ownership to third parties, and no copy
anywhere may imply otherwise.

##### J87. Settings is a tree, and no setting gets a paragraph. — *Don*

Don: *"the settings screen is gonna be kind of dense. It's gonna have a lot of settings in
sub-menus much like WhatsApp does... which is another reason why you don't want a lot of extraneous
explanatory text on the settings screen because it makes it impossible to cram a lot of settings
into a small space."* Rule: the top level of Settings is a short list of sections (Scope, Profile,
Privacy, Ways to reach you, Checking, Calls, Notifications, About) that push to their own page; a
control gets at most one short line under it, and only when its name is not enough.

---

## J102 — Verification by link, where the proof lives, and how far "bullet proof" actually goes

Don, 4 Sep 2026: *"the text should include a link that handles the verify step would be easiest as
opposed to typing a code but that will require access to a website but perhaps that verify stuff
could go on our public website which then could to the comm with the server if needed? how will we
save that verification - on app only in some way? we don't want someone to be able to present
someone else's number as if verified to steal their messages to need to make sure that's bullet
proof in some way"*

### 1. A link for email; a code for SMS

**Email verifies by link.** The message carries `https://<public site>/v/<token>`, the token being
32 random bytes and nothing else — not the address, not a hash of it, so the URL leaks nothing if it
is forwarded or logged by a mail scanner. Opening it marks the token used; the app, which has been
polling `check` since it sent the request, gets the proof back and ticks the box. Nothing is typed.

The one thing this costs is that a click and a poll are not the same session, so the app must say
*"waiting — open the link in the email"* rather than completing inline. That is how every magic link
works and users already understand it. **A six-digit code stays as the fallback in the same email**,
because mail clients rewrite links, some strip them, and some people read mail on a machine that is
not the phone.

**SMS keeps the code and does not get a link.** Links in texts are the shape of every phishing
message ever sent, carriers filter them aggressively, and shortened links are worse. A six-digit
code in a text is the convention for good reasons.

### 2. The public site as the front, which is the better half of the idea

Don is right that this belongs on the public site, and for a stronger reason than convenience: the
link in the email is **seen by the mail provider, its spam scanners, and anyone the mail is
forwarded to**. If it points at the relay, the relay's hostname is now in Google's and Microsoft's
logs, attached to an email address. Pointing it at the public marketing site puts the brand there
instead, and the site forwards the token to the relay server-side, where no third party sees it.

This generalises: **the public site is the front door for anything a stranger's software has to
touch** — verification links, support, `.well-known`. The relay's own hostname is then only ever
contacted by the app.

### 3. Where the proof lives: on the device, and nowhere else

The proof is `kind.hmac(value).expiry.signature` (`verify.mts`). The server signs it and forgets it
— there is no row anywhere saying this number is verified and no list to subpoena, seize or leak.
The app keeps it against the value the user typed; edit the value and no proof matches. That is
`J68a` and it does not change.

### 4. The honest answer on "bullet proof", which is that it is not, yet

The theft Don names is real and worth stating plainly. Discovery is write-only: anyone may leave an
invitation at `slot = H(value)`, and **the server does not check who collects**. So a modified
client can poll the slot for a number it does not own and pick up invitations meant for someone
else. The publishing gate — that the app will not offer a value for discovery until it holds a proof
— is enforced *on the device*, and a device the attacker controls enforces nothing (`J101`).

Three things narrow it, in increasing order of cost:

**(a) The conversation says Not verified, in both directions.** Whoever answers an invitation is a
stranger until a safety number is compared. An impersonator gets a conversation that is visibly
unauthenticated to the person they are deceiving. This does not stop the theft; it stops it being
invisible.

**(b) The invitation is not deleted when it is collected — implemented today.** *(Superseded in
form by `J105`: invitations moved to their own `/v1/invite` namespace, which never deletes on
collection at all, so the `keep=1` flag on the mailbox was removed as dead. The property below is
unchanged and is now structural rather than a flag.)* **The point is that the real owner sees the same invitation the thief
saw.** Without this, whoever polls first vacuums the slot and the owner never learns that anybody
tried to reach them. With it, impersonation becomes a race the victim witnesses instead of a theft
that is silent. Clients dedupe locally (`discoverySeen`) instead of relying on the server delete.

**(c) The actual fix is a blind capability, and it is a real build.** To close it properly the
server must require proof of control at *collection* time. A stateless capability — the server signs
the slot at the end of verification, and collection presents that signature — is cheap and reveals
nothing new to the server, since `collect` already carries the slot in the clear. But it hands the
server something it must not have: presented capabilities are a list of the hashed values of
verified users, and a phone number's hash space is small enough to brute-force offline. That is
precisely the identity database `J68c` refuses.

So the capability has to be **blind** — a VOPRF, Privacy Pass shaped — where the server signs
without learning what it signed and cannot link issuance to use. `J68c` already names this machinery
for handles. **It is the same build, and it is what "bullet proof" means here.** Until it exists,
(a) and (b) are what we have, and the honest framing for the store listing and the docs is that
discovery is trust-on-first-use with visible unverified status, not an authenticated directory.

### 5. What must not happen

No proof is ever accepted from another device, no proof is transferable, and the app never treats a
value as verified because a peer said so. Verification is a claim this device made to this server
about a value this user controls, kept locally, and re-checked when it expires.

---

## J103 — What Cloudflare is actually for here

Don, 4 Sep 2026: *"i think too you said something about using cloudflare to save videos in transit?
what else can we use that service for to save $ and help performance and privacy?"*

`ATTACHMENTS-AND-COST.md` and `CLOUDFLARE-SETUP.md` hold the arithmetic. The short version of what
the service buys us, in the order it is worth doing:

**1. R2 for attachments — the money.** Netlify bills bandwidth; R2 bills operations and gives egress
away. That single line inverts the conclusion that we cannot afford video: on R2 a video is cheaper
than the three photos somebody would have sent instead, because requests are what we pay for, not
bytes. "Videos in transit" is exactly right — an attachment is encrypted on the device, parked in
R2, and deleted on delivery. R2 never sees a key.

**2. A domain on Cloudflare turns on Encrypted Client Hello — the privacy.** Without ECH the
hostname the app connects to is in the clear on the wire for anyone watching the network, which on a
messenger is the one piece of metadata that matters most: *that this person uses this app at all*.
ECH hides it. It is free, and it needs the domain on Cloudflare, which is the whole reason Stage 2
of `CLOUDFLARE-SETUP.md` is ranked ahead of the cost work.

**3. Workers as the front door — the hostname hiding, and this is J102 §2 again.** A Worker in front
means the app talks to a Cloudflare edge address and the relay's real origin is never dialled by a
client. It is also where the verification link lands.

**4. Turnstile on verification — the abuse control.** SMS is the only part of this system that costs
real money per event, so it is the only part worth attacking. Turnstile is a privacy-preserving
challenge that does not profile the user, unlike reCAPTCHA, which we will not ship.

**5. Oblivious HTTP, later — the real prize.** A relay that cannot see the client's IP at all. It
needs the Worker gateway first, and it only means anything if the gateway is run by **a different
party** than the relay, which is a governance decision and not a configuration one.

What Cloudflare is **not** for: no analytics, no bot scoring on the message paths, no logging. The
edge is a pipe.

---

## J104 — Probing an imported contact's numbers and addresses, and why the answer can never be "No Profile"

Don, 4 Sep 2026: *"when you choose a phone contact to add to channel you should import all numbers
and all emails ... for each item i had hit a verify button in which case we will attempt contact for
the purpose of seeing if that phone or email or handle has been exposed by her - of course if she
has no account or has not exposed any of them we change that verify button to No Profile and if we
find it exists then that item should show the name of her profile ... so later if we choose her for
an outgoing message if there is more than one profile linked we choose the one we want to contact"*,
then *"she can do the same of course with us or anyone else"*, then *"we document well the degree of
certainty because if an email or phone is exposed on the net it's because that user proved they
controlled that account long enough to verify it"*.

Most of this is right and is being built. One part of it cannot be built as described, and it is the
part that looks most innocuous.

### 1. Import everything, locally — yes

Adding a phone contact brings across **every** number and **every** address on the card, each an
unchecked row in the contact editor. This does not touch the standing rule (*"no saving or uploading
of contacts is allowed"*): nothing is uploaded, nothing is hashed and sent, nothing leaves. The user
is looking at their own address book, in our app.

### 2. "No Profile" is a membership oracle, and it is the one thing `J68c` exists to prevent

An endpoint that answers *"does this number have a Channel account, and what is the profile called"*
is a **phone-number-to-name directory that anyone can download**. There are about ten billion
possible numbers. Checking them is not a hard attack or an expensive one — it is a loop. Whoever
runs it ends up holding the list of every Channel user with a published number, each one paired with
the name they chose. That list is exactly what a hostile government, a stalker, or a data broker
wants, and we would have built it for them and served it over HTTPS.

This is why discovery is **write-only** (`J68c`, `J91`): you may leave an invitation at
`slot = H(value)`, and **the deposit succeeds identically whether or not anybody is listening**.
Enumerating every number in the world yields nothing, because every answer is the same answer. The
moment one request can return "no such user", that property is gone and every other privacy measure
in the product — ECH, padded responses, rotating tokens, a relay that stores nothing — is protecting
a database we published on purpose.

**Don's own argument makes the oracle worse, not better.** He is right that a published value
carries real weight: it is there because somebody proved control of it during verification (`J102`
§3). But that means a scan would not return guesses — it would return *verified* name-to-number
pairs. The higher the assurance of each row, the more valuable the harvested list. The verification
work is what makes the oracle dangerous rather than what makes it safe.

### 3. What we build instead: the probe **is** an invitation

Everything Don wants from the button, except the negative answer, comes out of the existing
write-only path. Pressing **Reach** on a row deposits a pairing invitation at that value's slot and
starts listening, exactly as `J91` already does. Three states, and the wording matters:

| State | Shown as | What it actually means |
|---|---|---|
| Never pressed | **Reach** | We have not tried. |
| Pressed, nothing back | **Waiting** | Either nobody holds that value, or they hold it and have not opened the app. **We cannot tell, and neither can an attacker.** |
| Answered | **Lusmar (work)** | Somebody polling that slot ran the handshake. The row now names their profile. |

The middle row is the whole design. "No Profile" and "Waiting" differ by one word and by the entire
threat model. **Waiting never resolves to "no".** It ages out after seven days and offers to try
again.

The cost to the user is real and worth stating: the answer is not instant, and it depends on the
other person opening the app. That is the price of not shipping a directory, and it is the right
trade.

### 4. One request on her side, not six

Probing four addresses and two numbers for one person must not put six separate requests in front of
her. It does not have to: **every one of those invitations carries the same identity key — ours.**
Her app groups inbound invitations by sender key and shows one request: *"Don would like to connect.
He reached you at lusmar@work, lusmar@home, and +1 555 0100."* Accepting once links every value that
reached her.

This also tells her something she should know and would otherwise not: **which of her published
values a stranger already has.** That is a privacy feature falling out of a UI fix.

### 5. Several profiles for one person — yes, and it falls out for free

If two of her profiles answer from two different values, each answer is its own key agreement, so we
end up with two linked profiles under one contact card, each labelled with the value that reached
it. At send time, a contact with more than one linked profile asks which one — matching `J93`, that
no two profiles on a device may publish the same value, so a value always names exactly one profile.

### 6. Symmetric, by construction

*"she can do the same of course with us or anyone else"* — she can, and nothing here is
asymmetric. Both sides run the same code, both sides only ever deposit, and neither side can ask a
question the other cannot ask back.

### 7. The certainty we claim, written down

Don asked that we document the degree of certainty. Precisely:

- **A published value was proven.** Somebody completed an SMS or email challenge for it (`J102` §3).
  This is a genuine claim about the value.
- **The answerer is not authenticated.** Publishing is gated on the device, so a modified client can
  poll a slot for a value it never proved (`J101`, `J102` §4). Until the blind capability of `J102`
  §4 exists, "she answered" means "somebody polling that slot answered".
- **Therefore the contact is `discovery` / `unverified`,** and says so on its face, until two people
  compare a safety number. Not a decoration — the tier is the mitigation.
- **What we must never write in the UI:** "verified", "confirmed", or any phrasing that implies we
  know who answered. The row names the profile *they* claim; the badge says how much that is worth.

---

## J105 — Stopping a modified app from collecting invitations for a number it does not own

Don, 4 Sep 2026: *"so think about how we avoid someone else hopping in front of us with a modified
app or something to present a number as if verified when it's not - that may require some kind of
hashed storage or something or some other auth method to know it's at least basicaly trustworthy so
think about that"*.

This is the hole `J102` §4 and `J101` name. Here is the design that closes it, and a correction to
something I wrote in `J102` that made the fix look more expensive than it is.

### 1. The correction: the relay already sees the slot

I said in `J102` §4 that requiring a capability at collection would hand the server a list of
verified users' hashed values, and that only a blinded capability could avoid it. **The first half
of that is already true without any capability.** Discovery collection today is
`GET /v1/collect?token=<slot>`, and the slot is the hash of the number, in the clear, on every poll.
The relay operator can already harvest polled discovery slots and brute-force them offline — a phone
number's hash space is small.

So write-only discovery protects against **third parties**, which is what it was for, and has never
protected against the operator. Adding an authentication check at collection therefore costs almost
nothing in privacy that was not already spent, and it buys the thing Don is asking for. I had the
trade wrong and it made the cheap fix look unaffordable.

### 2. Layer 1: a signed collection capability. Cheap, stateless, ship it

**Issuance.** `verify/check` already returns a proof. It now also returns a **capability**:

```
cap = HMAC(VERIFY_SECRET, "cap/v1" ‖ slot ‖ devicePublicKey ‖ expiry)
```

`slot` is `H("channel/discover-v1" ‖ value)` — the same slot the apps already derive. Note what is
*not* in there: the number itself. The server hashes and forgets, exactly as `J102` §3 says, and
**stores nothing new** — the capability is verified by recomputing it, not by looking it up. That is
Don's "hashed storage", and the answer is that it needs no storage at all.

**Presentation.** Discovery gets its own namespace so the check cannot be side-stepped:

- deposit: `POST /v1/invite?slot=…` writes into an `invites` store (with `keep`/sticky from `J102`);
- collect: `GET /v1/invite?slot=…&exp=…&cap=…`, plus a signature over `slot ‖ exp ‖ timestamp` by
  the device key named in the capability.

The ordinary `/v1/collect` reads the mailbox store only and can never see the `invites` store, so a
modified client cannot get at invitations through the unauthenticated door. Deposits stay open to
everyone — anyone may *send* an invitation, which is the whole point.

**Binding to a device key matters.** Without it the capability is a bearer token: leak it once and
whoever holds it can collect that number's invitations for a year. With it, a stolen capability is
inert without the private key, and the timestamp in the signature stops replay.

**What this achieves.** A modified app can no longer publish a number it does not control and
harvest invitations meant for the owner. To get a capability it must pass the SMS or email challenge
for that value, which means controlling the value — which is the definition we wanted.

**What it does not achieve, stated plainly:** the relay operator can mint any capability, so the
operator can still collect any slot. That was already true, is true of every server-mediated
messenger, and is why the tier stays `unverified` until a safety number is compared. The safety
number is the check that no server can forge.

### 3. Layer 2, later: unlinkable capabilities and no IP

To take the operator out of it as well:

- **Blind issuance (VOPRF, Privacy Pass shaped).** The server signs without seeing what it signed,
  so issuance cannot be linked to use and the operator stops learning which verified values are
  polled. `J68c` already earmarks this machinery for handles; it is one build serving both.
- **Oblivious HTTP** (`J103` §5), so the relay never sees the client's address, and only means
  something if the gateway is run by a different party.

Layer 1 is a week's work and closes the attack Don named. Layer 2 is the real prize and should not
hold Layer 1 up.

### 4. Ordering

Layer 1 lands **before discovery ships to users**, because shipping the feature without it is
shipping the impersonation hole. `J102` §4(b) — invitations that survive collection so the owner
sees the attempt — stays regardless: it is the detection half, and detection and prevention are not
substitutes.

---

## J106 — Help in the app, exposing the whole documentation

Don, 4 Sep 2026: *"all this will need to be in the docs and help section of the app (another menu
item will be help for in app help) that will expose the entire documentation"*.

A **Help** item joins the menu on every platform, and it is not a FAQ stub: it renders the project's
own documentation, so what the user reads is what we actually decided.

- **One source.** Help pages are generated from the Markdown already in the repository rather than
  written a second time in a view. Two copies of an explanation means one of them is wrong within a
  month.
- **Bundled and offline by default. The app never reaches out for Help on its own.**
  Don, 4 Sep 2026: *"think of what, if anything, this does to security posture of device if it
  reaches out to check help content ... whether an offline help option should be default with an
  option to pull it only on demand for an update that a user could activate when on a public wifi
  for example. that might be more secure"*. He is right, and this reverses what I first wrote here.

  **The bar this has to clear.** Don, 4 Sep 2026: *"would not want a cruise missile to cost us a
  customer over refreshing a help page."* That is the threat model in one line, and it is the right
  one: for some of the people this product is for, the fact that they use it is the dangerous fact,
  not the contents of any message. A feature that quietly discloses *this device runs Channel* has
  given away more than an encrypted conversation ever would. **The principle generalises past Help:
  the app makes no connection to a branded domain it does not strictly need**, and any that it does
  make is one the user chose.

  **What an automatic fetch would cost.** A connection to the public site is a different signal from
  a connection to the relay: relay traffic is deliberately shapeless, but a request to the marketing
  domain says *this person is interested in Channel*, which for somebody at risk is exactly the
  disclosure the product exists to prevent. Worse, a fetch on launch or on a daily timer turns Help
  into a **usage beacon** -- the pattern of requests reports when the app is opened, to the CDN's
  logs and to anyone watching the network, whether or not we run analytics. Neither is worth the
  convenience of a silent update.

  So: the complete documentation ships **in the bundle**, is the default, and is never a stub. Help
  works with no network, on first launch, forever. Updates ride app releases, which is the ordinary
  channel and already mandatory-ish.

- **Learning that new Help exists costs nothing; fetching it is the user's choice.** The app already
  talks to the relay. A help-version marker rides on a response the app was making anyway, so
  discovering that a newer version exists adds **no new connection and no new observable signal**.
  Help then shows a quiet "updated help is available" line with a button. Nothing downloads until
  somebody presses it.

- **The update sheet says what it will do before it does it.** Plainly: this contacts the public
  website, and that server will see your address. Don's instinct to do it on public wifi is sound
  and the sheet says so rather than pretending it does not matter. No background download, no
  "while you were away" fetch, no retry loop.

- **Constraints on the webview itself, and these are not incidental.**
  - It renders **only local content**. Pages are downloaded, validated, then rendered from disk.
    Rendering straight from a URL would let the page pull fonts, images and scripts from third
    parties, so a single Help view would open a dozen connections we do not control. Help pages
    therefore carry **no remote resources at all** -- no web fonts, no CDN, no trackers, nothing.
  - **No pull-to-refresh and no reload gesture**, which is exactly Don's *"make sure the webview
    doesn't allow a manual refresh that breaks content when offline"*: a reload that hits the network
    is how a working Help page turns into an error page on a train. The only refresh is the explicit
    update button.
  - A download lands in a **staging copy and is swapped in atomically** only once it is complete and
    parses. A half-finished download never replaces working Help. If it fails, the old copy stays and
    the user is told the update did not happen.
  - JavaScript off unless a page genuinely needs it; no cookies, no local storage, no service worker.

- **Searchable, and linked from where the question arises.** The "Not verified" badge, the Reach
  row's **Waiting** state, and the verification screen each link into the page that explains them.
  A tier the user cannot look up is decoration.
- **English is normative** (standing rule): translations are a convenience and say so on the page.
- **What it must cover first:** what verified and Not verified mean and what a safety number proves
  (`J102` §4, `J104` §7, `J105` §2), why Reach says **Waiting** and never "No Profile" (`J104` §3),
  what a relay does and what enabling it exposes, and what leaves the device and what does not.

---

## J107 — Group chats

Don, 4 Sep 2026: *"we're going to need group chats too - our medical group has a few - one for
sharing birthdays among staff (family) one for all the providers and one that is physician only all
on whatsapp"*.

Three real groups, overlapping membership, tens of people not thousands. That shape decides the
design.

### 1. Pairwise fan-out first, not sender keys

A message to a group is sealed **separately to each member**, using the pairing this device already
has with them, and deposited as N ordinary envelopes. There is no group key, no new crypto, no new
wire format — a group message is a message with a group id on it.

The cost is N envelopes per send. At 50 members that is 50 deposits, which is nothing on R2 pricing
(`J103` §1) and well inside the mailbox limits. The benefit is that **the entire security argument
of the product carries over unchanged**: every message is still end-to-end sealed to one identity
key that this device verified, forward secrecy is per pair, and a compromised member cannot decrypt
anything not addressed to them.

Sender keys (one encryption, keys distributed once) is the right answer at thousands of members and
the wrong first answer here: it adds a key-distribution surface, a re-key on every membership
change, and a new class of bug, to save cost we are not paying. **Cap groups at 64 members** for now
and revisit if anyone hits it.

### 2. The server learns nothing about the group, and this is the strong claim

There is no group object on the relay. Membership, name, and history live only on members' devices.
N envelopes to N rotating tokens are indistinguishable from N unrelated one-to-one messages — the
relay cannot tell a group exists, let alone who is in it. That is better than WhatsApp, where the
server holds group membership, and it is worth saying plainly in Help.

### 3. Membership needs everyone paired with everyone — introductions are the missing piece

Pairwise fan-out requires each member to hold keys for every other member. In a group of twenty that
is not something people will do by hand.

So a group invitation carries **the roster: each member's display name and identity key, signed by
the member who sent it.** Receiving it creates contacts by introduction rather than by pairing, and
that deserves its own assurance tier:

| How the contact arose | Tier | Worth |
|---|---|---|
| Scanned or compared in person | **Verified** | Two people compared a safety number. |
| Introduced by a **verified** contact | **Introduced** | As good as the introducer, and no better. |
| Reached by number or address (`J104`) | **Not verified** | Somebody polling that slot answered. |

**Introduced is not Verified and must never render as it.** A member who adds a stranger has
introduced a stranger to everyone. Showing the introducer by name — *"in this group, added by
Lusmar"* — is what makes that judgeable, and it is what the tier is for.

### 4. Membership changes are signed and replicated, never authoritative from one side

Add and remove are messages, signed by the member making the change, applied by each device to its
own copy. Divergence is possible and is handled by showing it rather than hiding it: a member whose
roster disagrees is flagged, not silently reconciled. Leaving is local and final; removal is a
request every device honours for its own copy.

### 5. Profiles and groups

Groups belong to a **profile**, not a device (`J65`, `J93`). Don's own case makes the reason obvious:
the physicians-only group belongs on a work profile, the staff birthday group may not. Membership of
one group must never reveal membership of another, and two of his profiles in the same group is
exactly the ambiguity `J93` forbids — one profile per group per device.

### 6. What we do not claim, and it matters here

Clinicians in a group will discuss patients. Everything about the design helps — nothing on the
relay, no cloud backup, disappearing messages, no analytics — but **we must not claim HIPAA
compliance, and Help must not imply it.** A messenger is not a covered-entity compliance program,
there is no Business Associate Agreement, and saying otherwise would be a legal claim we cannot
support. Help states what the product does technically and leaves compliance to the practice.
English is normative (standing rule).

---

## J108 — A profile name is world-facing the moment a value is published

Don, 4 Sep 2026: *"make sure people know their profile name is potentially world facing depending on
their settings and connection preferences"*.

Correct, and it is not obvious to a user who has just typed a friendly name into a box.

**Where the name actually goes.** An invitation carries only an identity key and a nonce, so a
stranger who leaves one learns nothing. But the moment the user **accepts** a request, the ordinary
card exchange (`J84a`) sends their profile name and avatar. So the rule is: *anyone who knows one of
your published values and whose request you accept learns the name on that profile.* With
`J104`'s Reach flow that includes people probing an address book entry, who may be strangers.

**What the app must do.**

- **Say it at the moment of publishing, not in a settings page nobody opens.** Ticking **Find me by**
  is the point of disclosure, so the confirmation there states plainly: people who reach you at this
  value and whom you accept will see this profile's name and picture.
- **A profile's name should be choosable for the audience it faces.** A work profile published to a
  phone number wants a real name; a personal profile may want initials or a pseudonym. Nothing in
  the design requires the name to be a legal name, and Help says so.
- **Never auto-fill the profile name from the device name or the phone's account.** "Don's iPhone"
  as a default would publish a real name to strangers because a default was never changed.
- **Show the user what a stranger sees.** A one-line preview beside the tick: *they will see*
  followed by the name and avatar as rendered.

This also bounds `J104`: the Reach row can show a profile name only because the other side accepted.
An ignored request discloses nothing, which is the property that has to stay true.

---

## J109 — Losing the device: panic wipe, a duress code, and what is actually encrypted at rest

Don, 4 Sep 2026: *"should there be a kill switch that would permanently remove all messages from a
device ... could be linked to failure to auth ... maybe the device code is actually more secure than
face as a user might not be able to prevent someone else authing their phone with their face but
could prevent the code entry by just not revealing it ... also make sure the messages of all the
chats still on the phone are encrypted on the device and not trivial to read if the phone is lost"*.

### 1. First, the honest state of the app today

I checked both platforms before answering. **Neither encrypts message content at the app layer.**

- **iOS** writes the store as plain JSON with `.completeFileProtectionUntilFirstUserAuthentication`.
  That is a deliberate choice recorded in the file -- full protection would make the store unreadable
  whenever the phone is locked, which breaks background delivery -- but it means the protection is
  the operating system's, not ours, and the bytes are cleartext underneath it.
- **iOS profiles** are written with `.atomic` and no protection class at all, which is an oversight
  rather than a decision. Fixed in this pass.
- **Android** writes the store with `writeText` into `filesDir`: plain JSON, protected only by
  app-private storage and the platform's disk encryption. The identity *secret* is properly held in
  `EncryptedSharedPreferences` behind the Keystore; the messages are not.

So Don's suspicion is right. A phone that is seized after first unlock, or imaged by a forensic
tool, or rooted, gives up the message history. Sandboxing stops other apps; it does not stop that.

### 2. Encrypt the store under a hardware-held key

The store file becomes AES-256-GCM ciphertext under a key that lives in the **Secure Enclave** /
**Android Keystore** and never leaves it. Same availability as now -- released after first unlock,
so background delivery still works -- but the bytes on disk stop being readable at all without that
hardware. A disk image, a stolen backup, or a copied file yields nothing.

That is the default. Above it, an opt-in setting for people who want more:

**Lock messages when the app is closed.** The store key requires user authentication, so history is
unreadable while the app is not open. The cost is stated where it is offered: **messages cannot
arrive in the background** while locked. Real security, real trade, user's choice.

### 3. The kill switch, and its honest limits

- **An app cannot wipe the phone.** We can destroy our own data: every store, every profile, every
  identity secret, unrecoverably. We cannot trigger a device erase. Help points at iOS's *Erase Data
  after 10 failed passcode attempts* for that, as a device setting the user turns on themselves.
- **Triggers.** An explicit panic control that is reachable without unlocking first; **N failed
  attempts** on the app's own lock; and a **duress code** -- a second code that wipes instead of
  unlocking, so the user can comply with a demand and hand over a code that destroys.
- **Wipe means wipe.** Overwrite then delete, drop the Keystore key so any residue is undecryptable,
  and never leave a "restore" path on the device. A wipe that can be undone by whoever forced it is
  theatre.
- **Copies elsewhere survive.** Don is right that this is the point: wiping this phone does not touch
  a linked device (`J98`) or a backup (`J110`). That is what makes a panic wipe usable rather than
  catastrophic.

### 4. Biometrics can be compelled. A code cannot. This changes the design

Don's observation is the sharpest thing in the message and it is well founded: a face or a thumb can
be used on an unwilling person, and in several jurisdictions courts have treated biometrics
differently from a memorised passcode for exactly that reason. A code can be withheld by saying
nothing.

So:

- **The duress code and the panic wipe are code-only. Never biometric.**
- **A protected profile (`J97`) may be set to code-only**, so Face ID cannot open it even though it
  opens the phone.
- This needs the app's **own passcode**, not the device passcode: iOS's `LocalAuthentication` tries
  biometrics first and gives no way to demand the device code alone, so an app-level code is the only
  way to offer a genuinely biometric-free gate. It is also what makes a duress code possible at all.
- Biometrics stay the default for ordinary unlocking, because convenience is what keeps a lock
  switched on. The code-only tier is for those who want it, clearly explained.

### 5. What WhatsApp does, since Don asked

Fairly: less than this.

- Its app lock is a **gate on the interface**, not a second layer of encryption. Passing it is not
  required to read the database.
- The local message database sits in app-private storage protected by the platform. A rooted,
  jailbroken, or forensically imaged device can yield it, which is why commercial extraction tools
  advertise exactly that.
- **No panic wipe and no duress code.** There is nothing to type that destroys.
- Backups are where they did the real work: since 2021 an **opt-in end-to-end encrypted backup** with
  a 64-digit key or a password. Without it, the cloud backup is readable by the provider -- which is
  Don's point about Apple handing over iCloud backups but not device contents.

Our differentiators are therefore app-layer encryption at rest, the panic wipe, the duress code, and
a backup that is encrypted by default rather than as an option people never find.

---

## J110 — Backup: opt-in, encrypted to a key we never hold, and never resurrecting what was meant to vanish

Don, 4 Sep 2026: *"people do save all kinds of things in chats that would be really inconvenient to
lose if they lost their device in a lake or something so some kind of secure backup option would be
useful for many even if inherently less secure if used but again we're all about giving users
options with informed consent"*.

Agreed, and the lake is the common case -- far commoner than the adversary. A product that loses
everything on a dropped phone will be abandoned by ordinary people for reasons that have nothing to
do with privacy.

**The shape.**

- **Off by default, and never silent.** No automatic first-run backup, no "we noticed you have no
  backup" nagging that turns into a default yes.
- **Encrypted before it leaves, always.** The archive is sealed with a key derived from a user
  passphrase (Argon2id) or a 64-character recovery key generated and shown once. **We never hold the
  key and cannot recover it.** Said plainly at setup, twice, because it is the one irreversible part.
- **The user picks the destination**: their own iCloud Drive, Google Drive, a file they save, a
  computer. We do not run a backup service, which means there is no store of ours to compel.
- **Informed consent that is actually informative.** What the destination provider learns even though
  it cannot read the contents: that a backup exists, its size, and when it changed. That is real
  metadata and the screen says so rather than implying encryption makes it invisible.
- **A backup must not resurrect what was meant to disappear.** Disappearing and view-once content
  (`J88`, `J89`) is **excluded from the archive**, not merely re-timed. Restoring a message somebody
  believed had gone would break a promise made to the *other* person, who never consented to this
  device's backup settings. This is the constraint most products get wrong.
- **Restore is a pairing-grade event**, not a file drop: it re-establishes profiles and identity, so
  it runs behind the app code and is announced to linked devices (`J98`).

---

## J98 addendum — ten devices per profile

Don, 4 Sep 2026: *"allow each profile to be linked to up to 10 devices all linked with qr scan from
any other already auth'd device (wa allows 4 i think) for our two desktops and two laptops and maybe
3 phones"*.

Ten it is, and his own count is why: two desktops, two laptops, three phones is seven before anyone
has been generous. WhatsApp's four is a limit of their architecture, not a safety property.

Two consequences that must be built with it, because ten devices is ten copies of the history:

- **Any linked device can link another**, which means one compromised device can quietly grow the
  set. So: every device sees the full list with when and from where each was added, **adding a device
  notifies every other device**, and removal takes effect immediately and is not something the
  removed device can refuse.
- **Ten copies raises the stakes on `J109`.** Each linked device must carry the same at-rest
  encryption, its own lock setting, and its own panic wipe. A profile is only as protected as its
  least protected device, and the device list is where a user can see that.

---

## J111 — AI in a messenger whose whole claim is that nobody reads the messages

Don, 4 Sep 2026: *"for paid accounts we can even think about if there's anything useful be done with
the grok api within the app but i can't think of how ai would be helpful in a messaging app yet
other than auto replies or something ... nothing to code for that now but just thinking out loud"*.

Nothing is being built. This exists so the boundary is written down before somebody -- including a
future me -- adds a helpful-looking feature that quietly guts the product.

**The rule: message content never leaves the device to reach a model.** Not for a summary, not for a
smart reply, not for translation, not "anonymised", not "only the last few messages". A hosted model
call is a plaintext copy sent to a third party, which is precisely the thing every other decision
here exists to prevent. It would also be undetectable to the person on the other end, who never
agreed to it. This is the same reasoning that already rules out paid AI translation.

**What is therefore possible, and it is not nothing:**

- **On-device models only** for anything touching message text -- suggested replies, summarising a
  long thread, drafting. Apple and Android both ship on-device inference now; if a feature cannot run
  locally, it does not ship.
- **Content-free helpers** are unrestricted, because they never see a message: composing a first
  message from a prompt the user typed, help search, explaining a setting.
- **The Grok-bot idea for business users** is the interesting one and the most dangerous. If a
  business wants a bot in a conversation, the bot is **a participant with its own identity key**, it
  appears in the thread as a participant, and everyone can see it is there. A bot that reads a
  conversation without being visible in it is a wiretap with a friendly name. Done that way it needs
  no exception to anything above: the humans knowingly added a party that reads what they send it.
- **Never a default, never silent, and never on a paid tier that quietly buys weaker privacy.** Paying
  more must not mean protecting less; that inverts the product.

---

## J112 — No pre-made "Note to Self". The first profile is a required first step, and it is your own card

Don, 4 Sep 2026: *"I think the note self feature is going to create too much confusion because it
initial use of the app. We are going to create the first profile as a mandatory step and it will
come pre check as this is me and if the User wants to create a note to self. He will just choose
that profile as the destination of a chat, which is how pretty much every other app works."*

He is right, and this replaces the earlier "asked, not enforced" reading of `J83c`.

**What was wrong.** A brand-new user opened the app and found one contact already there, called
*Note to Self*, that they had not created and could not place. It reads as a feature when it is
really an implementation detail: the self card exists because the identity key needs an anchor and
because a card is how the app addresses anything. Naming that anchor after a *use* of it invented a
concept, and then a name-your-card prompt with a **Skip** button left people who skipped with a
permanent row called Note to Self and no idea why.

**What happens instead.**

- **First launch requires creating a profile.** Not an alert with a Skip, a step. It asks for a name
  and cannot be dismissed until there is one, because everything else -- the self card, discovery, a
  pairing code -- hangs off having one identity with a name on it.
- **That profile is the user's own card, pre-ticked "This is me".** One thing, not two. The name they
  typed is the name on the card.
- **Note to self is not a feature; it is a destination.** Choosing your own profile as the recipient
  opens a thread with yourself, which is how every other messenger does it and needs no explaining.
- **Existing installs are migrated**: a self contact still called *Note to Self* is renamed to its
  profile's name on launch. Nobody is left holding the old placeholder.

**What does not change.** The self contact still exists and is still the identity anchor -- `J25`'s
separation of contacts from conversations is untouched, and `Contact.isSelf` stays exclusive. Only
the name and the moment of creation change.

---

## J113 — A number typed any way must reach the same slot, anywhere in the world

Don, 4 Sep 2026: *"we need each to work worldwide. hopefully when we save numbers if a user only
saves 10 digits we need country codes by some means so the numbers are findable worldwide and don't
block discovery if someone enters a phone a different way than expected as people are not always
used to to"*.

This is the sharpest practical risk in discovery, and it is not a UI nicety. **The slot is
`SHA256("channel/discover-v1" ‖ value)`, so two spellings of the same number are two different
slots.** If Lusmar publishes `+34 600 123 456` and Don types `600123456`, the invitation is left at a
slot nobody is listening to, and it fails **silently** -- exactly the `Waiting` state that never
resolves (`J104` §3), with no way to tell a wrong format from an absent user.

So canonicalisation is a correctness requirement, not politeness.

### 1. One rule, identical on three platforms

`Discovery.normalize` today keeps a leading `+` and strips everything else, which means a national
number simply fails `isReachable` and the tick stays dead. The rule becomes:

```
digits   = the value with everything but digits removed
if the value began with "+"      -> E.164 is "+" + digits
else if digits begins with "00"  -> E.164 is "+" + digits without those two   (international prefix)
else                             -> national number:
     drop one leading "0"        (the trunk prefix in most of the world)
     if the device's country is +1 and there are 11 digits starting with 1, drop that 1
     E.164 is "+" + the device's calling code + what remains
```

A deliberately small, deterministic rule rather than a phone-number library: **iOS, Android and the
server must agree byte for byte or they derive different slots**, and three different libraries with
three different release cadences is precisely how that agreement breaks. Pinned by shared test
vectors the way `Discovery`'s slots already are.

### 2. The device's country, and letting the user correct it

The default calling code comes from the device (SIM region, else locale region). That is a guess, and
a guess that silently changes which slot a number lands in must be visible:

- The field shows the **resolved E.164 underneath as the user types** -- `600123456` becomes
  `+34 600 123 456`, so a wrong country is obvious before anything is published.
- A **country selector** sits beside the field, defaulted from the device and changeable. Don's own
  case makes this necessary: he has two SIMs, and a traveller's device region is routinely not the
  country of their number.
- What is **stored and published is always the E.164 form**, never what was typed. Display can be
  pretty; the slot is computed from one canonical string.

### 3. Be liberal about input, strict about the slot

Spaces, dashes, brackets, dots and non-breaking spaces are all stripped. A number that still cannot
be resolved is not silently rejected: the row says why, in place of the tick, rather than leaving a
checkbox that does nothing.

### 4. Email is easier but not trivial

Lowercased and trimmed, which is already done. We deliberately do **not** strip Gmail dots or
`+tags`: they are not equivalent at every provider, and guessing wrong sends an invitation to a slot
the owner is not listening on -- the same silent failure, for a rule that would only ever be a guess.

---

## J114 — A new profile can start as a copy of an existing one

Don, 4 Sep 2026: *"when creating a new profile allow to start copying info from another existing
profile including all settings but when created add a number to the old profile name until the user
re-edits that to whatever they want"*.

Most second profiles are a variation on the first, not a fresh start. Setting a dozen preferences
again is the kind of friction that stops people using profiles at all -- and profiles are the feature
the rest of the design leans on.

- **Create offers "Start from" with a profile picker**, defaulting to blank. Choosing one copies
  **settings only**.
- **The name is copied with a number appended** -- `Work` becomes `Work 2`, then `Work 3` -- and the
  field is focused and selected so renaming is one action. Don's instruction exactly: a working name
  now, changed whenever they like, rather than a modal demanding one.
- **What is never copied, and this is the important half:**
  - the **identity keypair** -- a new profile is a new identity, or it is not a separate profile at
    all (`J65`);
  - **contacts, conversations and messages**;
  - **verification proofs and capabilities** (`J102`, `J105`) -- proof of control belongs to the
    profile that proved it;
  - **published values**, because `J93` forbids two profiles on one device presenting the same
    number, so copying them would create the exact collision that rule exists to prevent. The
    **Find me by** ticks come across as **off**.
- Device-level settings (`J92`) are not copied because they are not per-profile; only the
  per-profile overrides are.

---

## J115 — Swipe: between pages, and on a row, without the two being confused

Don, 4 Sep 2026: *"a side swipe of whole page should move people between the 4 pages of the app ...
make it slick and seamless and animated ... then on contact page a swipe of just the contact could
offer options too just as a swipe on a particular chat ... try to copy more or less what whatsapp
does in these contexts ... but try to not confuse side swiping a list item with side swiping the
whole page too so we avoid accidental page changes"*.

The last clause is the whole problem, and it decides the design.

### 1. Why this is not one feature on two platforms

A horizontal drag that starts on a list row is ambiguous: it could mean *reveal this row's actions*
or *change page*. Whether that ambiguity can be resolved cleanly depends on the toolkit.

**Android can resolve it.** Compose's nested-scroll contract gives the child the drag first, and a
`HorizontalPager` only moves on what the child leaves unconsumed. A row that handles the swipe eats
it; a drag starting on the background reaches the pager. So Android gets exactly what Don asked for:
a real full-page swipe, animated, with row swipes living inside it.

**iOS cannot, and pretending otherwise would produce the bug he named.** `.swipeActions` consumes
horizontal drags on the row, but a `simultaneousGesture` on the container still sees the same drag,
and a row swipe travels far enough to trip any sensible page threshold. The result is a page change
every time somebody reaches for Archive. So on iOS the page swipe is **edge-initiated**: it must
begin within 44pt of the left or right edge, which is unambiguous, is the same gesture the system
already uses for back, and cannot be triggered from a row. Different mechanism, same intent, and it
is a platform difference we choose rather than a bug we ship.

### 2. Page order, and Help is not built yet

Messages, Contacts, Settings, and **Help** when `J106` lands. Swiping wraps, so one more swipe past
the end returns to Messages, as Don described. Until Help exists the cycle is the three that do,
rather than a placeholder tab that opens nothing.

Movement animates: the outgoing page slides out as the incoming one slides in, tracking the finger
rather than cutting on release, because the animation is what tells the user what the gesture did.
`prefers-reduced-motion` and the platform's reduce-motion setting shorten it to a cross-fade.

### 3. Leaving a detail page

Both platforms already do this natively and neither needs a new gesture: iOS's `NavigationStack`
has the interactive pop, Android has predictive back. The back arrow must go to the list it came
from -- Don, on landing after creating a contact: *"a back arrow should bring us to the contact
list"*.

### 4. Long press, not swipe, is how a row shows its actions

Don, 4 Sep 2026: *"a long press could be used on a list item if that's cleaner to expose the submenu
of actions on the list item ... as long as we can make it clean"*.

It is cleaner, and it is the better answer to §1. **A long press cannot be confused with a
horizontal drag at all**, so the ambiguity that forced two different mechanisms on the two platforms
disappears for row actions: both get the same gesture, and the page swipe keeps the whole width of
the screen to itself. It is also already how Android reaches a contact's detail page here, so it is
not a new idea to this app.

So: **long press opens the row's action menu** -- on a conversation, a contact, or a message. Swipe
on a row stays as an accelerator where the platform makes it free and unambiguous (iOS
`.swipeActions` on the conversation list, which already exists), and is never the only way to reach
anything.

### 5. Hints, and turning them off

Don: *"could even show an ephemeral hint 'long press to see options' and an option on config
(global) to turn on or off the hints so a pro user can turn them off and they will default as on
when a hidden feature requires an action a user might not guess"*.

A gesture nobody can see is a feature nobody has. So:

- **A one-line ephemeral hint appears where a gesture is the way in** -- *Long press for options* --
  and fades on its own.
- **It stops once the user has done it.** A hint that keeps appearing after somebody has learned the
  gesture is nagging, so each hint is retired the first time its gesture is used.
- **Show hints is a global setting (`J92`), default on.** Global rather than per-profile: it is
  about the person using the phone, not about which identity they are wearing. Off in one tap for
  anyone who does not want them, and off means off everywhere.
- **Hints never block.** No dialog, no button to dismiss, nothing that has to be answered.

### 6. Never fight the text field's own long press

Don, 4 Sep 2026: *"there could be long press options within a yet to be sent message too to expose
emojis or gifs ... if we can avoid conflicts with the os long press options in text fields but if
we're going to conflict better to have other icons below the text entry field (or above)"*.

We will conflict, so we take the second option. **A long press in the compose field belongs to the
operating system** -- select, copy, paste, look up, autofill -- and overriding it would break the
one interaction every user already knows, in the one place they are most likely to need it. That is
not a trade worth making for a shortcut.

So the extras live as **small controls beside the input**, not behind a gesture:

- **Paste**, which Don asked for twice: *"a paste button near the text input field ... something
  small, but big enough to be clicked on the phone"*. Visible, one tap, and it does not require
  knowing that a long press exists.
- **Emoji**, opening a picker.
- Attachment, which is already there.

They sit inline with the field rather than on a second row, so the compose area does not grow a bar
that costs a line of conversation on every screen.

### 7. Row swipes: what each list does

Following WhatsApp where it has an answer, and choosing something defensible where it does not.

| Where | Swipe left (trailing) | Swipe right (leading) |
|---|---|---|
| Conversation row | **Delete** (confirmed) and **Archive** | **Mark unread** |
| Contact row | **Delete contact** (confirmed) | **Message** |
| A message | **Delete for me** | **React** |

Two rules run through all of it:

- **Destructive actions are confirmed, never immediate.** Archive is one tap and reversible; delete
  removes messages for good. A swipe is far too cheap a gesture to hang that on -- which is already
  why the iOS conversation row confirms, and the rule now applies everywhere.
- **Nothing is hidden only behind a swipe.** Every swipe action also exists somewhere visible, in a
  menu or on the detail page. A gesture is an accelerator for people who know it, not the only door.

---

## J116 — The `+` beside the composer, and what we put behind it

Don, 4 Sep 2026, with a WhatsApp screenshot: *"Note the use of plus to the left of the text field,
we should copy that with as many of those items as we can easily do."* Their sheet holds Photos,
Camera, Location, Contact, Document, Poll, Event and AI images.

Copying the **shape** is right and cheap: one `+` where the photo button is now, opening a grid.
It stops the composer growing an icon per feature, and it is where users already look.

**What goes in it, and why not all of it.**

| Item | Us | Why |
|---|---|---|
| **Photos** | Yes, already built | The system picker, out of process; the app never holds a photo permission. |
| **Camera** | Yes | Same shape: capture and hand back one picture. Needs a `FileProvider` on Android for a full-size result rather than a thumbnail. |
| **Document** | Yes, later | The attachment transport already chunks (`PROTOCOL.md` payload type 6); what is missing is generic file support, since today it is images only. |
| **Contact** | Yes, later | The card format exists (`J84a`), but sending *somebody else's* card is a different thing from sending your own, and it hands a third party's details to a fourth. It gets a confirmation naming who is about to be shared. |
| **Location** | **No, for now** | It needs a location permission and turns a messenger into something that knows where you are. In a product whose claim is that it holds nothing about you, that deserves its own decision rather than arriving as the fifth icon in a grid. A one-off "send my current location" with no tracking and no history is the version worth considering. |
| **Poll**, **Event** | No | Real features, unrelated to anything this app does yet. |
| **AI images** | **Never** | `J111`: a generated image is a round trip to somebody else's model. It also puts an AI button inside the one screen where the product promises nothing leaves. |

The grid is labelled, like the screenshot: an unlabelled icon grid is a guessing game, and `J115` §5's
hints exist precisely because a feature nobody can find is a feature nobody has.

---

## J117 — The right-hand picker (emoji, GIFs, stickers), and AI on the user's own account

Don, 4 Sep 2026, with a second WhatsApp screenshot: *"note the other picker to the right, how it
exposes a three-part menu with things like emojis and gifs and they also expose an AI option to help
compose a message ... we could allow a user to post an API key for the common APIs ... that way the
user is using his own plan and not ours for AI features ... saved in the global settings section ...
we're not saving any of that information on the server anyway, so I think the security risk is
pretty limited"*.

### 1. The picker

A segmented control above the grid, exactly as in the screenshot: **Emoji · GIF · Stickers**. Emoji
first and free -- the platform ships the data, so it needs no network and no third party.

**GIFs are not free, and the cost is not money.** Every GIF keyboard is somebody else's search
engine: typing a word sends it to a provider, and fetching the image tells them what was picked and
from what address. In an app whose claim is that nothing about a conversation leaves, that is a live
connection to a third party in the middle of a chat. So GIF search is **off until it can be
proxied**, and when it ships the sheet says plainly that searching sends the word typed to the
provider. Stickers we ship ourselves carry none of that and can come first.

### 2. Bring your own key: what it does and does not change

Don's instinct -- the user's plan, not ours -- is right about **cost and control**. It is worth being
precise about what it does not fix.

**It does not make AI private.** `J111`'s rule is that message content never leaves the device to
reach a model. A key belonging to the user does not change where the text goes; it changes who is
billed and whose terms apply. The provider still receives the words.

So the boundary is drawn by *whose* words, not whose key:

- **Composing is defensible.** Helping someone rewrite a message they are drafting sends **their own
  text, that they are choosing to send**, to a provider they chose and pay. Nobody else's words are
  involved and nobody else's consent is needed.
- **Reading the conversation is not** -- *as an automatic behaviour*. Summarising a thread,
  suggesting replies from context, or translating what arrived sends **the other person's** messages
  to a third party who never agreed to it and who cannot see that it happened. Nothing may do that
  on its own, in the background, or by default.

  **Refined by `J118`:** Don's answer to this is per-use consent, and it is a real answer. The rule
  becomes *never silently*, rather than *never*.

The screenshot's AI button is a *compose* helper, which is the defensible half. That is the one to
build.

### 3. Where the key lives, and the honest risk

- **On the device only**, in the Keychain / Android Keystore beside the identity secret -- never on
  our relay, never in an unencrypted backup, never synced.
- **A global setting** (`J92`), as Don says: it belongs to the person and their account, not to a
  profile.
- **Off unless a key is present.** No key, no AI, no button.
- **The real risk is billing, not privacy.** A leaked key lets somebody spend the user's quota. That
  needs the device to be compromised, which is `J109`'s territory, and the app lock and at-rest
  encryption are what bound it. Help says this plainly rather than implying a key is harmless.
- **Every request is visible.** The compose helper says which provider it is about to contact before
  the first call, and never runs on its own.

---

## J118 — AI in a thread: the user's account, their prompt, and consent per use

Don, 4 Sep 2026: *"imagine if someone links their grok account and they're texting a dude and could
say to grok make me an image of a meme to make fun of this dude's last comment to me and grok could
then do it and paste the result in the send field ready to be sent after preview by the user of
course - that would cost us nothing and would be amazingly popular ... and of course you'd have to
opt in to passing message content to the ai as part of the prompt vs a user typed prompt so that any
privacy issue would be specifically requested by the user"*.

The last clause is the design, and it changes my position in `J117` §2.

### 1. Why the objection softens, and how far

I argued that sending the other person's messages to a model is indefensible because they never
agreed. Two things make that too strong.

**The sender already has those words.** They can select the message, copy it, and paste it into any
model today. The app making that one tap does not create a capability, it removes friction. A rule
that pretends otherwise buys no privacy and costs a feature people will use.

**Consent per use is a real control, not a checkbox.** Asking at the moment it happens, showing the
exact text that will leave, is meaningfully different from a setting buried in a menu that quietly
changes what every later action does.

So the rule is **never silently** rather than **never**:

- **Nothing automatic. Ever.** No background calls, no "suggested replies" that quietly send the
  thread, no summary generated before it is asked for.
- **Two prompt modes, and the default sends nothing of theirs.**
  - **Your words only** (default): only what the user typed is sent.
  - **Include this message**: the user picks the specific message or messages to include. The sheet
    then shows **the exact text that will be sent, verbatim**, before anything leaves. Not a
    description of it, not a count -- the text.
- **Preview before send, always.** The result lands in the compose field as text, or as a staged
  attachment for an image. Nothing is sent until the user taps send, exactly as Don describes.
- **Per conversation, not global.** Including message content is chosen for the thread it happens
  in, and it lapses -- it does not become a standing permission the user forgets they granted.

### 2. What this unlocks, including one thing we had ruled out

- **Compose and rewrite** -- the defensible core, only the user's own draft.
- **Images and memes** -- Don's scenario. Generated by the user's provider, staged as an ordinary
  attachment, sent end-to-end like any other picture. The relay sees a sealed blob as always.
- **Translation.** Don, earlier: *"i don't want to pay for ai translate for this app"*. On the
  user's own account that objection is gone -- it costs us nothing. `J39`'s on-demand translation
  gains a much better engine for anyone who has connected one, and stays on-demand.
- **Analysis** -- summarise a long thread, on request, under the same consent.

### 3. Providers

API keys first, because they are one field and work everywhere: OpenAI, Anthropic, Google, xAI.
"Connect your account" by OAuth is nicer and is per-provider work; it can follow. A small capability
table per provider (text, image, translation) drives which entries appear, so the sheet never offers
an action the configured provider cannot do.

Keys live where `J117` §3 puts them: the Keychain or Keystore, never on the relay, never in an
unencrypted backup, off unless present.

### 4. What Help has to say, plainly

That the other person cannot see this happened. That a model provider is a third party with its own
terms and retention, and that "your own key" means your account and your bill, not privacy. That
what was sent is shown before it is sent, every time, and that this is the only place in the app
where a conversation's words can leave the device at all.

---

## J119 — You cannot compose to somebody who may not exist, and finding out is the Reach

Don, 4 Sep 2026: *"Lusmar has a number identified, and then I go to message to start a new chat ...
hers does not [appear], so how could I send her a message? ... we should not allow someone to create
a message to someone whose existence has not been verified ... they do need to exist and we have to
find a match to something that they chose to present and do it in the most secure way possible"*.

He is right about the requirement and right that the old behaviour was a dead end: filtering her out
of the picker left him with a contact, a number, and nothing to do.

### 1. The question underneath: when do we find out?

Don asks whether to check when the number is entered, or when a message is attempted. **Neither,
and this is the important part: there is nothing to check.**

Asking a server *"does this number have an account"* is precisely the membership oracle `J68c`
refuses -- a phone-number-to-name directory anyone can scan. We will not build it, so no amount of
waiting produces a yes-or-no.

What we have instead is the invitation (`J91`, `J104`): leave a pairing payload at the slot derived
from the number, and see whether anyone answers. That **is** the existence check, and it is the most
secure one available, because it tells nobody anything unless the owner chooses to answer.

### 2. So the flow

- **An unpaired contact with a reachable value appears in the picker.** She is somebody you might
  message; hiding her answered nothing.
- **Choosing her starts a Reach, not a thread.** A sheet names the values an invitation could go to,
  says plainly that nobody is told whether she is there until she answers, and deposits one.
- **No compose field until there is a pairing.** This is Don's rule and it is the right one: writing
  a paragraph into a box that may have no recipient is the frustration he is describing. There is no
  thread to type into until somebody accepts.
- **The request lands on her side** (`J68d`) and, if she accepts, the pairing completes and the
  thread exists for both.

### 3. What the user is told, and what stays true

*"Nobody is told whether they are there, including you, until they answer."* That is not a hedge, it
is the property: a deposit succeeds identically whether or not the slot has an owner, which is what
makes enumerating numbers useless. The **Waiting** state of `J104` §3 never resolves to "no", and
this sheet says so in the same words.

---

## J120 — Location, and the arrow WhatsApp does not have

Don, 4 Sep 2026: *"We should do location the way WhatsApp does it. It is an intentional privacy
breach and that is allowed, and you have the option to show your location right now or to update
your location with every server connection for a fixed time period ... One other feature that
WhatsApp does not have that I think would be useful is to show an arrow on the map if live location
is enabled that will help us navigate to the other person ... if you're in a shopping mall or an
airport or a park and you're trying to meet the other person"*.

This reverses `J116`'s hold on location, and the reversal is well argued: **an intentional
disclosure the user asks for each time is not the same thing as a product that knows where you are.**
The refusal was about the second; this is the first.

### 1. Two modes, both explicit

- **Send my location now** -- one point, one message, no updates. An ordinary attachment: sealed,
  end-to-end, and the relay sees a blob like any other.
- **Share live for a fixed time** -- 15 minutes, 1 hour, 8 hours, and it **ends by itself**. No
  indefinite option, because that is how live location becomes tracking nobody remembers enabling.
  Visible in the thread the whole time it is running, with one tap to stop.

Updates ride the connections the app already makes -- Don's *"update your location with every server
connection"* -- rather than opening a new channel or waking the radio on their own schedule.

### 2. Where the map comes from, per platform

- **iOS**: MapKit. On the device, no key, no third-party request.
- **Android**: Google Play Services Maps where it exists; **osmdroid** against OpenStreetMap where
  it does not, which also covers de-Googled devices. Either way the tiles come from a third party,
  so a location message renders a map only when opened, never in the background, and never for a
  message the user has not looked at.

A tile request tells the tile server roughly where the user is looking. That is worth one honest
line in Help rather than pretending a map is free.

### 3. The arrow, which is the good idea

WhatsApp shows you a map and leaves you to work out which way to walk. Don's case -- a mall, an
airport, a park -- is exactly where a map is least useful, because the hard part is not *where they
are* but *which way to turn*.

So while live location is running and both sides are sharing: **a bearing arrow pointing at the other
person**, with the distance beside it.

- **Heading comes from the compass** (`CLLocationManager.heading` / `SensorManager` rotation
  vector). Standing still, a compass still knows which way the phone is pointed, which is Don's
  *"you don't always know which way I'm facing if I'm stationary"*.
- **Where the compass is unreliable** -- indoors, near metal, exactly where a mall is -- the heading
  falls back to **course over ground** from consecutive fixes while moving, and the arrow says which
  it is using rather than pointing confidently in the wrong direction.
- **Distance, always.** "40 m" tells you more in a mall than any map.

### 4. What is refused

Location is **never** attached automatically, never inferred from a photo's EXIF (`J57` already
strips it), and never retained after a live share ends -- the trail is not kept, on either device,
because a history of where somebody was is the single most dangerous thing this app could store.

---

## J121 — Importing from the phone: browse, inspect, then decide

Don, 4 Sep 2026: *"when you press the phone, it doesn't really show you the phone contacts, it just
exposes that button about copy from phone. It might be better if it just showed you the contact
picker with a search field and then when you select an individual contact, it will show you the
contact details along with a button import to channel so that you could look at the contact detail
before deciding"*.

The current Phone tab is a button that explains a policy. That policy is right -- `J121a` below --
but a screen whose entire content is a button is a screen that has failed.

### 1. The flow

1. **Phone tab opens the system picker directly**, with its own search. No intermediate button.
2. **The chosen card is shown before anything is created**: name, every number, every address --
   what `J113`/the import already reads -- with **Import to Channel** underneath. Look first,
   decide second.
3. **Import lands on the Channel contact detail page** (`J112`'s rule that creating something puts
   you on it), where the fields are editable.
4. **What needs correcting is flagged there**: a number that will not resolve to E.164 asks for its
   country (`J113` §2); an address that is not an address says so. This is the right moment --
   after import, before verification, on a page the user is already looking at.
5. **The app marks are chosen there too**: which of her numbers is on WhatsApp, Telegram, Signal.

### 2. Can we check whether a number is on WhatsApp? No

Don asks, and suspects the answer. **WhatsApp exposes no way to ask this.** There is no public API,
and a `wa.me` link resolves the same whether or not the number has an account -- deliberately, for
the same reason we refuse a lookup of our own (`J68c`): it would be an enumeration oracle for their
user base.

So the marks are the user's own knowledge, exactly as Don says: they can look the person up in
WhatsApp themselves and tick accordingly. The app should not pretend to more certainty than that,
which is why the mark is a claim on a card and never a badge that says "verified on WhatsApp".

### 3. One number for chat, another for calls

Don: *"the profile could indicate one number to be used for contacting the other person for chat,
while another phone number could be used to contact the person for a voice call passed off to
WhatsApp"*. That already falls out of the model and is worth stating: the **Find me by** tick and
the **app marks** are per number and independent. A card can carry a number ticked for discovery and
a different number marked for WhatsApp calls, and nothing forces them to be the same.

### `J121a` — why the picker, still

Unchanged from the existing note: the system picker hands back the one contact chosen and nothing
else. No `READ_CONTACTS`, no permission prompt, no enumeration, and the standing rule that no
contact is ever uploaded holds. Browsing through the OS is what makes "look before you import"
possible without the app ever seeing the address book.

---

## J122 — An app PIN of our own, and a distress code that really destroys

Don, 4 Sep 2026: *"add an optional pin code beyond what the OS offers, because some people may not
lock their phones at all ... required optionally to enter the app, but also to authenticate entry
into a profile, and if the person selects to use a pin and to use device authentication, both would
need to be passed ... a distress code also defined by the user, that if entered a small confirm
button would appear saying only confirm, and if that is pressed, all data associated with the app
would be removed and not recoverable"*.

This is right, and it completes `J109`. One part of the mechanism needs correcting.

### 1. The PIN

`J109` §4 already concluded the app needs **its own passcode**, because iOS gives no way to demand
the device code while skipping biometrics, and because a duress code is impossible without one. Don
adds the better reason: **a lot of people do not lock their phones at all.** For them the OS gate is
not weak, it is absent, and everything `J81`'s app lock claims to do is doing nothing.

- **Set per device** (global, `J92`) for entering the app, and **per profile** (`J97`) for entering a
  protected profile.
- **PIN, biometric, both, or neither** -- and *both* means both, in sequence. A user who asks for two
  factors gets two.
- The PIN is never stored. A slow KDF over it (Argon2id) yields a verifier; wrong guesses are rate
  limited with an increasing delay, so a four-digit code is not brute-forced in seconds.

### 2. The distress code, and why it is a second code rather than a wrong-PIN counter

A wrong-PIN threshold punishes a fumbling owner. A distress code is **entered deliberately, under
coercion, and looks exactly like unlocking.** Don's confirm step is right: one small button, so a
mistyped PIN that happens to collide cannot destroy everything by accident.

**What it must look like afterwards, and this is the part that keeps the user safe:** the app opens
as if freshly installed. No "data erased" banner, no empty state that says something used to be
here. If the screen announces that a wipe happened, the person holding the phone knows the code they
were given was a lie, and the user is in more danger than before. A distress wipe that advertises
itself is worse than none.

### 3. Overwriting with `0xFF` does not work, and something better does

Don proposes overwriting every byte so nothing is forensically recoverable. On a spinning disk that
was right. **On the flash in a phone it does not do what it appears to.** Writes go through a
translation layer with wear levelling: overwriting a file writes *new* physical pages and marks the
old ones stale, and the originals sit in unmapped cells the operating system cannot address at all.
The overwrite gives confidence without the property.

**Crypto-shredding is the mechanism that works.** Once the store is encrypted under a key held in
the Secure Enclave or the Android Keystore (`J109` §2), destroying that key makes every byte of
ciphertext undecryptable no matter which physical cells survive. It is what Apple's own *Erase All
Content and Settings* does, and it is why that takes a second rather than an hour.

It is also the right answer for a *distress* wipe specifically, on two counts a duress situation
cares about:

- **It is instant.** Overwriting gigabytes takes minutes and can be interrupted by someone taking
  the phone away. Discarding a key cannot be half-done.
- **It is atomic.** There is no window in which some messages are gone and others are not.

So the order is: **destroy the store key, destroy the identity keys, then delete the files.** The
deletion is tidiness; the key destruction is the security.

### 4. Reaching the server, and what the server can honestly do

Don asks whether the app can tell the relay to drop pending messages. **Yes, and it needs no new
endpoint:** the device knows its own mailbox tokens and can confirm-delete what is waiting, which is
the same call an ordinary delivery makes. Best effort only -- it needs a network, and a distress wipe
must never wait on one. Anything left expires by TTL regardless (`J6`).

**"Reject any request to send until the account is rebuilt" cannot work, and should not.** There is
no account: the relay holds no identity, no registration, and no notion of who a token belongs to
(`J9`). There is nothing to lock. What actually happens is better -- after the wipe, the old tokens
belong to nobody, so anything deposited at them is never collected and expires unread. Achieving the
same thing by adding a "blocked" flag would mean the relay keeping exactly the durable per-user state
the design exists to avoid.

---

## J123 — The verification flow, and why SMS keeps the typed code

Don, 4 Sep 2026: *"when I sent the code, it immediately said the code did not match, and of course
it never asked me to enter a code, and it never sent a text message either ... when you say
unverified, it has to look like a button"*.

### 1. What was actually broken

The dialog computed the canonical E.164 form of the number and then **sent the raw typed value to
the server anyway**. The relay accepts E.164 only and refuses anything else with a 400, which the
client maps to `Rejected`, whose message is *"That code did not match."* So typing a number the way
everybody types it produced a complaint about a code that had never been sent, for a text that was
never dispatched.

Proven against the live relay: `8435551234` returns 400, `+18435551234` returns 200.

Three fixes, both platforms:

- **Send the canonical value.** `Discovery.normalize` already produced it; nothing used it.
- **Say the right thing.** A refusal *before* a code exists is about the number ("That number was not
  accepted. Check the country and the digits."); only *after* one is sent does it concern the code.
- **Make the control look like one.** *"the unverified did not look like a button, but I touched it
  anyway"* -- it is a bordered **Verify** button now. A control nobody can see is a control nobody
  uses, and calling it "Unverified" described a state where a verb was wanted.

### 2. Typed code or a link? Code for SMS, link for email

Don asks which is better and leaves the call here. `J102` §1 already split them, and his experience
supports it:

**SMS keeps the six-digit code**, and the friction he is worried about mostly is not there:

- **iOS autofills it.** The code appears above the keyboard as a one-tap suggestion, because the
  field declares `.oneTimeCode`. Nobody reads digits off a notification and retypes them. Android
  can do the same through the SMS Retriever API, which needs the app's hash in the message body and
  no permissions -- worth adding.
- **A link in a text is weaker, not stronger.** It can be opened straight from a lock-screen
  notification preview by anyone holding the phone, which makes it a one-tap credential visible
  without unlocking. A code has to be typed *into our app*, which at least requires the app.
- Carriers filter SMS containing links hard, and a link in a text is the shape of every phishing
  message ever sent -- poor training for users of a privacy product.

**Email keeps the link**, with the code as a fallback in the same message: there is no autofill for
codes in email, and a link is what people expect there.

---

## J124 — Handles as a third way to be found, and why they need what numbers do not

Don, 4 Sep 2026: *"you need to build at some point the ability to enter a unique handle as a third
means of discovery for incoming messages, and that of course requires the table we talked about"*.

Noted, and he has put his finger on exactly why this one is harder than the other two.

**A phone number and an email address need no registry.** The sender already knows the value, so the
slot is `H(value)` and nobody has to be asked anything (`J68c`). Discovery is write-only precisely
because the sender supplies the secret.

**A handle has to be unique, and uniqueness needs an authority.** `@lusmar123` means nothing unless
something guarantees only one person holds it, and that something is a table -- which is the
membership oracle every other part of this design refuses. Worse than a number: a handle namespace
is small and guessable, so an enumeration attack against it is cheap and the result is a directory.

So a handle cannot simply reuse the number path. What makes it possible is the machinery `J68c` and
`J105` §3 already earmark:

- **Registration is blind.** The server issues a proof that a handle is taken without learning which
  handle it is -- a VOPRF, Privacy Pass shaped. It can then refuse a duplicate without holding a
  readable list.
- **Collection stays capability-gated** exactly as `J105` does for numbers, so only the holder polls
  the handle's slot.
- **A handle is never a lookup.** As with `J104`, reaching one leaves an invitation and waits;
  nothing answers "is this handle taken" to a stranger.

Which means handles land **after** `J105` §3's blind issuance, not before. Building them on a plain
table would be the one thing this product has consistently refused, and doing it for convenience
would undo the rest.

---

## J125 — When nobody answers, offer to invite them the way you already reach them

Don, 4 Sep 2026: *"ideally, if you find that none of the numbers or emails or handles are present in
the app, you offer the ability to send a message via one of the other routes which could be a text
message, could be a WhatsApp message, could be a telegram message, depending on which things have
been checked, and that message would include the invite to download the app and might even offer the
option to give a public key"*.

This is the right answer to a real dead end, and it needs one correction to the premise.

### 1. We still cannot "find that none of them are present"

`J104` §2 and `J119` §1: there is no lookup, and a deposit succeeds identically whether or not the
slot has an owner. So the app can never say *she is not on Channel* -- only *nobody has answered
yet*.

That does not block this feature; it changes what triggers it. **The trigger is silence, not
absence.** After an invitation has sat unanswered for a while -- or immediately, if the user says
"they probably don't have it" -- the same screen offers the fallback. No oracle required, and the
user gets a route forward instead of a `Waiting` that never resolves.

### 2. What the fallback does

The contact card already records which apps each number is on (`J86`, and `J121` §2: those marks are
the user's own knowledge, since WhatsApp exposes no way to check). So the offer is built from what is
already ticked:

- **Text message** to a number, via the system composer.
- **WhatsApp / Telegram / Signal**, via the same handoff the call button uses (`J86a`) -- which opens
  a conversation rather than sending on its own, so the user sees and sends the message themselves.

**The app never sends any of these silently.** It composes; the person taps send in the other app.
That keeps it out of the business of messaging on someone's behalf through a channel we do not
control.

### 3. Carrying a pairing code in the invitation, and what that is worth

Don's *"might even offer the option to give a public key"* is the valuable half. The invitation can
carry the sender's `PairingPayload` -- the same 66-character code the pairing screen shows -- so when
the recipient installs and pastes it, they are paired immediately, with no need to guess which of
her addresses is published. It removes the whole problem he describes.

**The tier is `codeRemote`, and it must say so.** A code sent over SMS or WhatsApp is exactly `J40`'s
"code sent to you": anyone who can rewrite that message can substitute their own code, and the two
people have not compared a safety number. So the resulting contact is **Not verified** until they do
-- better than nothing, weaker than meeting, and labelled honestly rather than presented as though a
key exchange over WhatsApp were the same as one done face to face.

### 4. Why this matters more than it looks

It is the answer to the cold-start problem. Until now the only ways to reach somebody were pairing in
person or discovery, and discovery only works if they already have the app *and* published the value
you happen to know. This closes the loop with the thing everybody actually has: an existing way to
message that person.

---

## J126 — The invite by text, and why her key comes back on its own

Don, 4 Sep 2026: *"what you just need to send in the text message is a code that she can use to link
to us as a verified contact ... the message might say 'Don Elton would like to connect via channel.
Paste this code into your channel app to start a conversation'"*, and then: *"something would have
to return back to me in some way to have her key code that conceivably could be part of the first
message she sends back or part of the initial handshake"*.

### 1. The key comes back by itself. This already works

Don's worry is the right one to have and it is already solved. **The code in that message is a
`PairingPayload`** -- his identity key and a fresh attempt nonce, the same 66 characters the pairing
screen shows. When she pastes it, her app runs the ordinary scanner path (`J40`), and the three
flights travel on rendezvous slots both sides can compute from that nonce. **Her key comes back on
flight two.** No first message needed, nothing to bolt on.

Verified on two emulators today: one side showed a code, the other pasted it, and both ended paired
with keys exchanged. **One exchange, not two** -- the "Your half is done" screen that appears
afterwards is left over from the split-code path and is misleading here, since pairing has already
completed. That screen needs fixing.

### 2. Choosing where it goes

Don: *"we could be asked to choose which of those three options in her case, the message would be
sent to."* So the invite starts from the values already on her card:

- **A number** opens the SMS composer addressed to that number, body prefilled.
- **An email** opens the mail composer addressed to it.
- **WhatsApp / Telegram / Signal** use the same handoff as the call button (`J86a`), for numbers
  marked as being on those apps.

The app **composes and never sends**. The person taps send in the other app, so we are never
messaging on somebody's behalf through a channel we do not control.

This rests on Don's own point: *"this requires that the person sending this message knows that she
has control of both those numbers."* It does. That is the sender's own knowledge, exactly as the app
marks are (`J121` §2), and it is why the tier below is what it is.

### 3. The wording, near enough Don's

> Don Elton would like to connect on Channel.
>
> Paste this code into your Channel app to start a conversation:
>
> `05X524…B6N04`

During the beta there is no link, so finding the app is left to the reader -- which is fine while the
only recipients are testers. Later the same message carries a store link, and later still a universal
link that opens the app with the code already filled in.

### 4. The tier, and why it is not "verified"

Don calls it *"a verified contact"*, and it is worth being exact: this is `J40`'s **`codeRemote`** --
a code sent to you over a channel we do not control. **Anyone who can rewrite that text message can
substitute their own code**, and the two people have not compared a safety number. So the contact is
real, keyed, and able to carry messages, and it shows as **Not verified** until they compare numbers
in person or on a call where each knows the other's voice.

That is not pedantry. The whole tier system exists so that the one case where somebody *did* meet in
person is distinguishable from the case where they trusted a text message.

---

## J127 — Reach every value at once, and how much an encrypted carrier really buys

Don, 4 Sep 2026: *"can I create a contact for somebody I've never texted before and putting in all
the contact methods I know for that person ... my phone needs to reach out and see if a message is
possible or not using those methods through the app ... For someone to get in the middle of that,
they would have to somehow see the message in transit but sending through either iMessage or through
WhatsApp is encrypted, so that would seem pretty unlikely"*.

### 1. Yes, and it now reaches all of them together

The flow he describes is the design, with one fix made here: **the invitation goes to every value on
her card at once**, not one at a time. We cannot know which address she published, so asking one at
a time is guessing, and each guess costs a week of waiting for an answer that was never going to
come from that slot.

One nonce covers all of them, so a single listener completes the pairing whichever slot she answers
on, and her side groups the requests by sender key into one (`J104` §4) rather than showing four.

Still no lookup: each deposit succeeds identically whether or not anyone is listening, so this
reaches more places without learning anything new.

### 2. His risk assessment is largely right, and the residual risk is not where he thinks

Don argues that iMessage and WhatsApp are end-to-end encrypted, so intercepting the code in transit
is unlikely. **That is correct, and it does make `codeRemote` over those channels meaningfully
stronger than over plain SMS.** Wire interception is not the thing to worry about.

What remains is not interception:

- **The wrong number is the real risk.** Numbers get recycled constantly, and a digit can be wrong.
  Send the code to a number she no longer holds and a stranger pastes it and becomes "her" in the
  app -- with no attacker, no interception, and nothing to notice. This is the dominant real-world
  failure and it is exactly what comparing a safety number catches.
- **iMessage silently falls back to SMS.** Green bubble, no encryption, and SIM swaps are routine.
  Somebody can believe they sent it over iMessage when it went as a text.
- **The code is a bearer token sitting in a message history.** Anyone with her unlocked phone, or her
  iCloud, can redeem it before she does. Encryption in transit does nothing about storage at rest on
  either end.
- **WhatsApp can re-key an account** on recovery. It warns; people click through.

### 3. So the tier stands, and it costs almost nothing

The contact is real, keyed, and carries messages. It shows **Not verified** until safety numbers are
compared -- and the upgrade is trivial precisely because these two people are about to talk anyway:
one screen, ten seconds, on the first call. The tier is not a penalty, it is a prompt, and it is what
distinguishes "we met" from "I trusted a message".

Worth showing **which channel the code went by**, since the tier's real strength differs between
WhatsApp and a green-bubble SMS, and only the sender knows which happened.

---

## J128 — The texted code: one use, 24 hours, and no clock in the message

Don, 4 Sep 2026: *"it is probably best that the code actually texted [is a] one time use code that
maybe represents the key but is not the final key, it's just a one time use key to get in the door
that expires in 24 hours ... since you don't know the time zone they will open the message [in],
they just know 24 hours from the received date and time that shows on their phone"*.

Right on all three counts, and building it found a bug.

### 1. The bug: nobody was listening

The invite composer generated a payload and wrote it into the message **without registering a
listener** on its rendezvous slot. A code with nobody waiting on it pairs with nothing: whoever
pasted it would have got silence, and the sender would never have known why. It now goes through the
store, which starts the listener as it mints the code.

### 2. One use and 24 hours, with no server state at all

Both properties fall out of the listener rather than needing anything on the relay:

- **One use**: the listener stops the moment somebody completes the exchange. A second redeemer finds
  nobody there.
- **24 hours**: the invitation ages out, and a texted code carries `lifetimeHours = 24` instead of
  the week a discovery invitation gets. A late redeemer finds nobody there either.

The relay never learns the code existed. That is the right place for the rule: the sender is the only
party who needs to enforce it.

Why shorter than a discovery invitation: an invitation left at a slot cannot be read by anyone, so
there is nothing to steal. **A texted code is a bearer token sitting in two message histories**, so
the window in which a stranger could redeem it should be short.

### 3. No absolute time in the message

Don's reasoning is exactly right and worth keeping: we do not know their time zone, nor when they
will open it, and a wrongly-rendered clock time is worse than none. So the message says *"only for 24
hours from when you received this message"* -- relative to something their phone already displays
beside it.

### 4. What is still to do: the code should not carry the long-term key

Don's *"represents the key but is not the final key"* has a part not yet built. Today the payload
carries the **long-term identity public key**, so anyone who reads the message learns a key that
appears in every pairing this device ever does -- a linkability leak, even though the key is public
and grants nothing on its own.

The fix is an **ephemeral pairing key**: the code carries a throwaway public key, and the long-term
identity is authenticated inside the encrypted handshake. That is a change to `mesh-core-rs` and the
handshake transcript, so it is real work and is recorded here rather than done in passing.

---

## J129 — Comparing safety numbers: any second channel, and why not this one

Don, 4 Sep 2026: *"we still need to compare the numbers. Could that not be done via the text message
that we send within the app? Or does it have to be by voice?"*

### 1. Not inside Channel. That part is circular

Sending the safety number **through the conversation you are trying to verify proves nothing**. The
attack being checked for is somebody sitting in the middle of that exact channel -- and anyone in
that position can rewrite the number as it passes, showing each side whatever makes the comparison
succeed. The check has to travel by a route the attacker would have to compromise *separately*.

### 2. It does not have to be voice

Any genuinely different channel works, because it forces an attacker to be in the middle of two
unrelated systems at once:

- **WhatsApp, iMessage, Signal** -- different providers, different keys. Good, and easy.
- **A voice call** -- better, for a reason that is not about encryption: you recognise the voice, so
  it authenticates *the person*, not merely the channel. A text from a compromised account is still
  a text.
- **In person** -- best, and the only one where both hold.

So the practical answer for Don and Lusmar: paste the safety number to each other on WhatsApp and
read it. Ten seconds, and meaningfully strong. Saying it aloud on the call they are having anyway is
better still.

### 3. Verify whenever, and what pairing already bought

Don: *"at any time if you wanted to verify the user, you could send them the public key one time use
code ... so that in the future the contact does not depend on that phone number staying public."*

Correct, and worth separating two things that are easy to run together:

- **Pairing** (the code exchange) creates the durable key relationship. **This is what frees the
  conversation from the phone number** -- once paired, messages route by keys and rotating mailbox
  tokens, and it no longer matters whether she still publishes that number, or keeps it at all.
- **Verifying** (comparing safety numbers) does not change routing at all. It changes what the app is
  entitled to claim about *who* the key belongs to.

Both are worth having and neither substitutes for the other. Don's instinct that a number somebody
has used for years and still uses on WhatsApp is strong evidence is fair -- and it is evidence about
the number, not proof about the key that answered. Which is why the badge says what it says.

---

## J130 — Push is not built. There is no setting because there is no feature

Don, 4 Sep 2026: *"I don't see anywhere where you can enable push notifications, so that you bypass
all that polling behavior ... This should be part of the global settings, [and] could also be by
profile, since an individual profile might insist on only polling for its messages so that it could
be a bit more secret agent."*

He is looking for a switch that does not exist, and the honest answer is that **neither app has push
at all**. Notifications today are entirely *local*: the app polls, finds mail, and raises a
notification itself. `LocalNotifier`'s header says it outright -- *"NEVER call
`registerForRemoteNotifications()`"*.

The **server half is built**: `register.mts` stores `notifyId -> pushToken`, `deposit.mts` takes an
optional `notify` and wakes that device (`J44`/`J46`), and `heartbeat.mts` is the content-free
fallback. Nothing on either client ever calls any of it.

### 1. Why this matters more than a missing toggle

`ATTACHMENTS-AND-COST.md`: at 100,000 users, payload costs about **$80 a month and polling about
$1,300**. Push is not a convenience feature here, it is the difference between those two numbers --
and on the device it is the difference between a message arriving now and arriving at the next poll.

### 2. What building it actually needs

Not a small job, and it needs things only Don can create:

- **iOS**: an APNs key on the developer account, the Push Notifications capability, and the entitlement.
- **Android**: a Firebase project and `google-services.json`, plus the FCM dependency.
- **Both**: get the token, hand the relay an opaque `notifyId -> token` row (`register.mts`), give
  paired contacts that `notifyId` so senders can attach it (`J46`: the sender supplies it and the
  relay stores no map from mailbox to device), and handle the wake by polling.

### 3. The setting, once it exists -- and Don's "secret agent" profile is the right shape


- **Global, default on**: *Wake me when mail arrives*.
- **Per profile, and genuinely independent.** Don, 4 Sep 2026: *"a profile not wanting push shouldn't
  prevent push from profiles that want it, because you still wouldn't be linking to that particular
  profile as far as the Internet is concerned. Profiles are different users."* Correct. One profile
  declining push says nothing about the others and must not disable them. The only asymmetry kept is
  that a global default cannot switch a profile back **on** -- a quieter profile must not be made
  louder by a device-wide setting it never agreed to.

### 4. The catch, which is real and decides who should decline

**APNs and FCM issue one token per app install, not per profile.** Every profile shares the device's
single token, so:

- **Contacts cannot correlate profiles**, provided each profile that enables push gets its **own
  `notifyId`**. A contact of one profile and a contact of another then hold different opaque ids, and
  comparing them reveals nothing. This is a requirement, not an optimisation: reusing one `notifyId`
  across profiles would let two colluding contacts prove the profiles share a device.
- **The relay can.** It stores `notifyId -> pushToken` and must know the real token to call APNs at
  all, so two profiles using push on one device appear as two ids with the same token. There is no
  way around it: the wake has to reach a device, and the device has one address.

So Don's model holds against the internet and against other users, and does not hold against the
relay operator, for exactly the profiles that both use push.

Which turns the per-profile switch from a curiosity into the thing that answers it: **a profile that
must not be linkable to your others should decline push and poll.** That is the "secret agent"
profile with a concrete reason attached, and it is why the switch has to exist rather than being a
device-wide yes or no.
- The copy has to be honest about the trade `J44` already records: a targeted wake means this
  device asked to be reachable at a stable push token, which is a durable identifier the
  content-free heartbeat avoids. That is the actual privacy cost, and it is why the polling-only
  option exists rather than being a curiosity.

---

## J131 — Verifying a number that does not receive texts

Don, 4 Sep 2026: *"She added a number and we sent a message to verify but it went via SMS, but she
only uses that number with WhatsApp, so when we verify we need to ask by what channel."*

`J123` settled *code versus link* and assumed the code always arrives by SMS. That assumption is
wrong for a large share of the world: plenty of numbers are WhatsApp-only in practice, and some --
landlines, certain VoIP numbers -- cannot receive a text at all. Sending SMS and waiting is then a
dead end with no error, because the send succeeds and nothing arrives.

### 1. Ask, rather than assume

The verify sheet gains a channel choice before it sends anything, listing only what can actually
work for that value:

| Channel | Status | Why |
|---|---|---|
| **Text message** | built | The default where it works, and iOS autofills the code (`J123`). |
| **Voice call** | next, and cheap | Telnyx reads the digits aloud. This is the answer for a landline or an SMS-blocked number, and it needs no new provider -- the same account and number. |
| **WhatsApp** | later, and not cheap | Meta's Business API, business verification, an approved template and per-message pricing. Worth doing because Don's case is common, but it is a procurement exercise, not an afternoon. |
| **Email** | built | Already the better path where an address exists (`J102`). |

### 2. Why voice comes first

It closes most of the gap for a fraction of the effort. A number that cannot take a text can almost
always take a call, and Telnyx does both from the account already configured. WhatsApp only helps
people who have WhatsApp; voice helps everyone.

### 3. A phone with no SIM changes the answer

Don, 4 Sep 2026: *"Her phone — this one — has no SIM and is WiFi only, hence needs WhatsApp to
receive message."*

That rules out **both** SMS and the voice call §2 recommended, since neither exists without
cellular service. It also exposes something the table above glossed over: **verifying a number by
WhatsApp proves control of the WhatsApp account, not of the number.** A WhatsApp registration
outlives the SIM, so if that number is later reissued, the person holding the new SIM controls SMS
while she still controls WhatsApp. Two different people, two different proofs, one value.

**And for Don's actual goal it is beside the point.** Verification exists so a value can be
*published for discovery* -- so that somebody who knows only her number can reach her. He is not in
that position: he already has a way to reach her. So the route is `J126`'s invitation, sent over
WhatsApp, with no verification of anything:

1. Invite by message, choosing the **WhatsApp** route for her number.
2. She pastes the code; the rendezvous exchanges keys; they are paired.
3. Compare safety numbers over that same WhatsApp thread (`J129`) to reach **Verified**.

Nothing above needs her to receive a text, and the resulting pairing is stronger than a
number-verified discovery contact would have been.

So the invitation composer now offers **every route a value carries** -- SMS *and* each app the card
says she uses on that number, listed only when this phone has that app installed. Offering only SMS
was offering the one route that could not work.

### 4. What must not happen

**Never silently fall back.** If the user picks SMS and it is undeliverable, say so and offer the
call -- do not quietly try another channel, because the whole point of verification is that the
person knows which route proved control of that value.

And the tier does not change with the channel: proving control of a number by answering a call is
the same claim as proving it by reading a text. What changes is only whether the proof is *possible*.

---

## J132 — Proving control of a number nobody can text

Don, 4 Sep 2026: *"She wants that number she uses on WA to also be discoverable in Channel, and we
can't let her do that unless she can prove she controls that number."*

Exactly right, and it is `J101`'s rule: a value nobody proved is inert in both directions, because
publishing a number you do not control means answering invitations meant for whoever does.

So the question is what "controls" means for a number whose SIM is not in any phone she has.

### 1. The one that works today, and may be all she needs

**The code does not have to arrive on the device that publishes the value.** It is six digits. If
that SIM is alive in *any* phone -- hers, a spare, a drawer -- the text can arrive there and the
digits be typed into Channel on the WiFi-only phone. The proof binds to the value, not to the
handset that received it.

That is worth trying first, because it needs nothing built.

### 2. If the SIM is truly gone, she does not control the number, and that is the point

If no device anywhere can receive on that number, then in the sense discovery cares about she does
not control it -- and if it has been reissued, **somebody else does**. Letting her publish it would
let her collect invitations addressed to the person now holding it. That is precisely the
impersonation `J105` exists to prevent, and it does not stop being that because she is the one
asking.

### 3. So WhatsApp verification is a real answer, but it is a *different* proof

Verifying through WhatsApp proves control of **the WhatsApp account registered to that number** --
which is what actually persists when a SIM does not. For a number that is, in practice, a WhatsApp
identity, that is the honest thing to prove, and it is what the people trying to reach her are
relying on anyway.

But it is not interchangeable with an SMS proof, so it must not be recorded as one:

- **Store which channel proved it** alongside the proof, and say so where the value is shown --
  *proved via WhatsApp* rather than a bare tick.
- **Discovery still works**, because the slot is derived from the number either way.
- **The distinction matters** exactly when the number has been reissued: the WhatsApp holder and the
  SIM holder are then two different people, both able to prove "control" by their own route. The app
  should be able to say which one it saw.

This is the same principle as the assurance tiers: two different proofs, both real, never collapsed
into one word.

### 4. Order of work

1. **Try the SIM-in-another-phone route now.** No code.
2. **Email** where an address exists -- already built and works over WiFi.
3. **WhatsApp OTP** via Meta's Business API, recorded in `J131`, with the channel stored beside the
   proof as above. This is the one that answers Don's case properly.
4. **Voice** stays useful for landlines but does nothing here.

---

## J133 — WhatsApp verification: what it takes, what it costs, and what it tells Meta

Don, 4 Sep 2026: *"So see if you can get the Meta API access ... and see what else it offers that we
can use here or other apps we have."*

### 1. What already exists, and the thing to be careful about

He has a Meta Business portfolio, and it is **Trialsforme**. Building Channel's WhatsApp under it
associates the two, which the standing rule about `trialsforme` staying secret forbids. **Channel
needs its own business portfolio**, not a WhatsApp app hung off that one.

### 2. Testing costs nothing and needs no verification

Cloud API creates a **test business phone number and a test WhatsApp Business account
automatically**, with relaxed limits, **no payment method**, and free messages to **up to five
recipient numbers**. That is enough to prove the whole flow and to verify Lusmar's number, today,
without business verification or a bill.

### 3. Production, and its real gates

- **Business verification.** A new portfolio is capped at two registered business phone numbers;
  verification (or reaching a 2,000-recipient messaging limit) raises it to twenty.
- **A dedicated number.** The number the *service* sends from cannot be one already on consumer
  WhatsApp. The Telnyx number bought today would serve.
- **An approved authentication template.** Codes must go in an authentication-category template, and
  the code must be sent only to a number whose owner asked for it.
- **Per-message pricing** since July 2025, by the recipient's country. A handful of countries carry a
  higher *authentication-international* rate -- Egypt, India, Indonesia, Malaysia, Nigeria, Pakistan,
  Saudi Arabia, South Africa, the UAE. The US, Spain and Venezuela are not among them.

### 4. The privacy cost, which is the part worth arguing about

Sending a verification code through WhatsApp tells **Meta** that this phone number is registering
with Channel. For most products that is unremarkable. For this one it is the exact class of fact the
design spends its whole effort not collecting -- and we would be handing it to the largest advertising
company in the world, per verification.

That does not make it wrong; it makes it a choice that belongs to the user rather than a default:

- **Never the default channel.** SMS and email first; WhatsApp offered where the number cannot
  receive a text, which is precisely Don's case.
- **Say so at the moment of choosing**: *"Meta will see that this number is verifying with
  Channel."* Not buried in Help.
- Store which channel proved it (`J132` §3), so the record says what actually happened.

### 5. What else Meta offers that is worth taking

- **For Channel:** essentially only this. Cloud API Calling and the messaging products are for
  businesses talking to customers, which this app is not, and `J111` already rules out routing
  message content through anyone's model.
- **For the other apps, this is a better fit than it is here.** Utility templates on WhatsApp suit
  TrialsForMe (trial matches, appointment reminders) and TitleVitals (sales and review alerts) far
  better than a privacy messenger -- those are genuinely business-to-customer messages, the category
  the platform is built for and priced for.

### 6. What Don has to do, because I will not

Creating the Meta app means registering as a developer and accepting platform terms, and business
verification means submitting company documents. Those are his to sign, not mine to click. Once the
app exists I can do the rest: add the WhatsApp product, wire `verify.mts` to send the code through
it, submit the authentication template, and record the channel beside the proof.

---

## J134 — The voice code: what it costs, and what language it speaks

Don, 4 Sep 2026: *"Her SIM is in her son's phone and he can give us the code — I'm mainly wanting to
test each method of onboarding, and WhatsApp and SMS are two of them. What does the voice option
cost and how will that work in other languages?"*

### 1. SMS is unblocked for testing

The SIM being in her son's phone is exactly `J132` §1: the code arrives there, the six digits are
typed into Channel on her WiFi-only phone, and the proof binds to the number rather than the
handset. Nothing to build.

### 2. Cost: about the same as a text

From Telnyx's own rates: US outbound voice is **$0.002/minute** on the Voice API plus the SIP
trunking fee, worked as **~$0.005/minute** in their example. Text-to-speech is billed per character
— **$0.000003** on Telnyx TTS, **$0.000024** on Amazon Polly neural.

A verification call is *"Your Channel code is 1 2 3 4 5 6. Again: 1 2 3 4 5 6."* — around sixty
characters and under half a minute, though calls bill to a one-minute minimum. So roughly **half a
cent to a cent per call in the US**, which puts it level with an SMS rather than above it.

International voice is a different matter and varies enormously by destination — far more spread
than SMS. `VERIFY_SMS_MONTHLY_CAP` must count calls too, or the budget has a hole in it.

### 3. Language: the recipient's, and only they can settle it

The person who has to understand the call is the one being verified, and the app knows nothing about
what they speak. So:

- **Default from the number's country**, since the calling code is the one real signal — a `+34`
  number gets Spanish.
- **The chooser offers the language**, next to the channel, and it is changeable. Don's own case
  makes the point: an American phone calling a Spanish speaker should not read digits in English
  because the sender's phone is in English.
- **Fall back to the app's locale**, then to English, and never silently — if no voice exists for the
  language, the sheet says which one it will use.

Two details that matter more than the voice does:

- **Read the digits as digits**, with SSML `interpret-as="digits"`, or "123456" is spoken as "one
  hundred twenty-three thousand..." in most languages.
- **Say it twice, slowly, with a pause between.** Somebody is writing it down, probably in a second
  language, possibly from a voicemail.

### 4. What it needs building

Voice is not the Messaging API. It needs a **Call Control application** in the Telnyx portal — the
voice equivalent of the messaging profile created today — and `verify.mts` gains a branch that dials
and speaks rather than sending a text. The number bought today is voice-capable, so nothing further
is purchased.

### 5. Onboarding routes worth testing, which is Don's actual ask

1. **QR / code in person** — the strongest, and already working (verified between two devices).
2. **Invitation by SMS** — code pasted, pairs on one exchange.
3. **Invitation by WhatsApp** — same, and the only route to a phone with no SIM.
4. **Discovery by verified email** — works today over WiFi.
5. **Discovery by verified number, SMS code** — unblocked by the son's phone.
6. **Discovery by verified number, voice code** — this entry.
7. **Discovery by verified number, WhatsApp code** — `J133`, needs the Meta app.

---

## J135 — Push, actually wired: the credentials, and the line J5 drew

Don, 4 Sep 2026: *"So we didn't have this already wired for the silent time pushes?"*

No. `J130` said push was not built; this is the half that could be built without him.

### 1. What existed, and the distinction worth keeping straight

The **relay** was complete: `register.mts` stored `notifyId -> pushToken`, `deposit.mts` woke the
device when a sender attached a `notify`, `heartbeat.mts` woke on a schedule regardless. But `wake()`
in `_shared.mts` was **deliberately an empty function**, with a comment saying so — left unimplemented
rather than stubbed with a fake success, so no caller could mistake *no credentials* for *delivered*.

The **client** half never existed at all. Neither app has ever asked iOS or Android for a push token,
so nothing was ever registered and nothing was ever woken. Every notification either app has shown is
`LocalNotifier`: the app polls, finds mail, and raises the notification itself.

### 2. Why it was absent by design, and what changes it

`J5` is emphatic, and `LocalNotifier`'s header still carries it: **never call
`registerForRemoteNotifications()`**, because *"its absence is the entire privacy claim of direct
delivery — Apple never learns this app is installed on this device."* For a message crossing the room
over Bluetooth, that holds completely and always will: the app is already running, and there is
nothing for a push service to do.

It stops holding for a message crossing the internet. That one already goes through the relay, and
the choice is not *push or privacy* but **push or polling**, where polling costs `$1,300` a month
against `$80` at 100,000 users and delivers late. So `J130`'s switch is the reconciliation, and the
per-profile "secret agent" mode is `J5` preserved exactly as a choice: a profile that never registers
a token is a profile Apple still never hears about.

### 3. What was found and set today

- The **APNs key already existed** — `48HA7NAS59`, created for TitleVitals but **team-scoped to all
  topics**, so it signs for Channel without minting anything new. Team `34LMDLB33Y`.
- **Push Notifications was not enabled** on the `com.channelmessenger.app` App ID. That was the real
  blocker, and it is now on. Enabling it invalidates existing provisioning profiles; automatic
  signing regenerates them on the next build.
- The key is staged at `secrets/` (git-ignored) and set on the relay as `APNS_KEY_P8`, **base64**:
  a PEM begins with five dashes that every CLI reads as an option, and its newlines do not survive a
  web form.

### 4. Two traps in the sending code, both silent

- **APNs requires HTTP/2, and Node's `fetch` is HTTP/1.1 only.** It answers with what looks like a
  network fault. Hence `node:http2` and a hand-built request.
- **A JWT needs a raw `R‖S` signature; Node signs EC keys as DER by default.** APNs rejects a DER
  signature as a bad token — the same error it gives for an expired key. `dsaEncoding:
  "ieee-p1363"` is the fix.

Also: `apns-push-type: background` with `apns-priority: 5` is the only combination Apple accepts for
a payload carrying nothing but `content-available`. At priority 10 it is discarded.

### 5. What the wake carries, which is nothing

`{"aps":{"content-available":1}}` on iOS, a data-only message with an empty data map on Android. No
content, no sender, no conversation id, and **no indication of whether there is mail** — `heartbeat`
fires on a schedule either way, so Apple and Google see a rhythm, not a delivery log (`J10`, `J41`).

`wake()` never throws and never blocks a deposit. The envelope is in the mailbox before it is called,
and the recipient's next poll collects it regardless. Push is an optimisation over polling, not the
delivery path — which is exactly why a profile can decline it and lose only promptness.

### 6. Still needed

- **The client half on both platforms**: request the token, `POST /v1/register`, hand paired contacts
  the `notifyId` (`J46`), poll on wake. Plus `J130`'s global and per-profile switches.
- **Android's credential.** `com.channelmessenger.app` is currently registered inside the
  **TitleVitals** Firebase project (`titlevitals-app`), so an FCM service-account key for it would
  give the Channel relay access to TitleVitals. For the app whose entire pitch is separation, Channel
  should have its own Firebase project.

---

## J136 — A code that never arrives, and why nothing said so

Don, 4 Sep 2026: *"She hit the button. There was no error message, but the text message never
arrived, so check that flow."* And then, correctly: *"When there is an error, you should have code to
identify that there was an error, because sometimes it will be for some other reason, like a
malformed number."*

### 1. What actually happened, demonstrated rather than guessed

`sendSMS` returns `res.ok` — **and a `200` from Telnyx means *accepted for delivery*, not
*delivered*.** Reproduced against production: `+15005550001`, a US number that can receive nothing,
returned `{"ok": true}` and `HTTP 200`. Telnyx took it and the message went nowhere.

So the app was told the truth as it knew it. There was no error to show, because at the only moment
we look, there was no error. The loss happens afterwards, at the carrier, and we never look again.

### 2. Three defects, not one

**a. We could not tell an accepted message from a delivered one.** Fixed by `/v1/delivery`, a webhook
for Telnyx's delivery receipts. It stores **a tally per day per status and nothing else** — no
number, no message id, no code. A delivery receipt names its recipient, and `A6` rules out keeping
anything that reconstructs who was contacted, so the number is read in order to be discarded. The
carrier's *reason* is kept, because that is about the route and not the person: "unregistered" is the
difference between a bug and a wrong number. Thirty-five days, expired opportunistically.

**b. Every send failure said the same wrong thing.** A `502` mapped to `unavailable`, whose copy is
*"Verification is not switched on yet."* When the real cause is a typo, that sentence sends someone
to look at the app instead of at what they typed. The relay now classifies:

- `badNumber` — the provider says that is not a textable destination.
- `unreachable` — well formed, but no route carries it: a landline, or a country that refuses our
  sender.
- `provider` — our fault, and the fallback, because blaming ourselves is the right way to be wrong.

**This is not an oracle.** `PROTOCOL.md` §0 forbids errors that distinguish whether a *mailbox*
exists. These distinguish only the shape and reachability of a value the caller supplied and already
knows, and say nothing about whether anyone is registered at it.

**c. We contradicted ourselves about the messaging profile.** When `from` was a number Telnyx owns,
the payload *also* named a profile — and `discoverMessagingProfile` takes whichever profile the
account happens to list first, which need not be the one that number belongs to. Telnyx resolves the
profile from the number itself, so the extra field could only ever disagree with it. Now sent only
for an alphanumeric sender, which genuinely needs it.

### 3. What is still unknown, and it needs Don

Why US messages are accepted and dropped. The overwhelmingly likely answer is **10DLC**: a new
long code sending application traffic to US numbers is filtered by US carriers until its brand and
campaign are registered, and the API accepts throughout. Confirming it means reading the message's
delivery record in the Telnyx portal, and registering the webhook above against the messaging
profile — both need a Telnyx login, which is Don's to give.

Until then the honest position is that **SMS to US numbers is unproven**, and the email route — which
is verified working end to end — is the one to test onboarding with.

---

## J137 — The number was never stored with its country code, and the country is a guess

Don, 4 Sep 2026: *"The number display on her contact does not show the country code, but it should
even when she's not editing, because the number was saved supposedly with a country code."* Then:
*"Problem the display and not how the number was saved or how the number was sent to the texting
agency?"*

### 1. It is the saved value. The display is faithful

Everything that *uses* a number runs it through `Discovery.normalize` first — the discovery slot, the
verification code, the proof it is filed under. Only `save()` skipped that step and stored the raw
text. So a number typed the ordinary national way was kept without its country code, and shown that
way forever after. It was never "saved with a country code"; it only looked that way from the inside,
because every consumer added one on the fly.

Both platforms now canonicalise to E.164 on save — but **only when the result is genuinely
reachable**. A half-typed number, or one entered before the device knows its own calling code, is
kept exactly as written. Silently rewriting something we cannot resolve is worse than leaving it.

### 2. The number sent to Telnyx was correct, so this did not cause the missing text

`VerifyValueView` sends `Discovery.normalize(value)`, never the raw field, and the relay normalises
again. The SMS went to an E.164 number. The display defect and the undelivered code are separate
faults.

### 3. But the country in that E.164 number is a *guess*, and that is not separate

`Discovery.deviceCallingCode` comes from `Locale.current.region` — **the phone's region setting**.
For a national number it is simply prepended. So if the device's region and the number's country
disagree, the app resolves to a real-looking number in the wrong country, deposits nothing at the
right slot, and texts a stranger or nobody. `J113` §3 anticipated this; what it did not have was any
way for the user to notice.

Fixing §1 is what makes it noticeable: once the saved value is the resolved E.164, the contact shows
`+1 …` and a wrong guess is visible before anyone waits for a text that cannot arrive.

**This is now a leading candidate for Lusmar's undelivered code**, alongside 10DLC — her handset has
no SIM, so its region is whatever iOS was set to, not where her number lives.

### 4. What is not fixed, and must not be mistaken for fixed

None of `J136` or this entry makes an SMS arrive. `J136` makes a failure *legible*; this makes a
wrong country *visible*. The delivery question is still open and still needs the Telnyx portal.

---

## J138 — Saved bare, shown faithfully. Nothing was ever stripped

Don, 4 Sep 2026: *"Are we saving the number as bare and thus showing what we saved, or is it just the
display is stripping the country code?"*

**Saved bare.** Checked rather than assumed:

- `save()` passed `phones` straight to `store.updateContact` with no canonicalisation.
- `ContactDetailView` printed `Text(field.value)` — the stored string, verbatim.
- Nothing anywhere removes a country code. The only two `dropFirst(2)` in the codebase parse the
  `CallingCodes` table and convert an international `00` prefix; neither touches a stored value.

The `+1` visible while editing was `ContactEditView`'s live preview — `Discovery.normalize(field.value)`
rendered as a caption under the field. Computed on the fly, never written down, which is exactly why
it disappeared on leaving the form.

### What that means for the fix

`J137` corrects what is **stored**, and only from now on. Every contact saved before it still holds a
bare number, and asking someone to re-save each one by hand is not a fix. So `Discovery.forDisplay`
corrects what is **shown**: the resolved E.164 where we can resolve it, the raw text where we cannot.
Applied at every site that shows a number to a person — the contact card, the verify sheet's title
and both of its sentences, the invite routes — on both platforms.

### Telnyx expects E.164, and the `+1` is never stripped

Don asked whether to strip it for US numbers. Checked against Telnyx's own specification: `to` is
*"a +E.164 formatted phone number"*, their example being `+18665550001` — the `+`, the country code,
then the ten national digits. Removing the `+1` produces `Invalid Phone Number Format` and the
message is never sent.

So the rule is one-directional: **always append, never remove.** The country picker already rewrote
the field to E.164 when touched; `J137` now applies the same resolution at save whether it was
touched or not.

---

## J139 — The country belongs to the picker, the whole number belongs to storage

Don, 4 Sep 2026: *"She enters starting 407 and thinks the +1 displayed by the picker is part of the
number, but if you're just saving 10 digits and not 12 that's your bug."* He is right, and `J137`
fixes it. But fixing it creates the opposite problem, which he saw immediately: *"When you edit a
number that already has the +1 you have to strip the country code when you show the number to be
edited with the country picker still there, otherwise it looks like you're making a number like
+1+1407..."*

### 1. Two options, and why storage keeps the whole number

Don offered the alternative: *"save the country code in a separate field and append at time of using
the number."* Rejected, and not narrowly.

A second column means **every consumer must remember to join the halves** — the discovery slot, the
verification send, the invitation, the import, both platforms. The one that forgets produces a
ten-digit number that Telnyx refuses and a slot nobody listens at, and neither says so. That is
precisely the failure this whole sequence has been about, and a separate field would institutionalise
it rather than fix it.

So: **one stored truth, always complete E.164.** The split is a property of the *editor*, not of the
data. The field shows the national part, the picker states the country, and every keystroke
recomposes them into the stored value. Nothing downstream knows the decomposition happened.

### 2. The one case that would silently steal a number

Typing `+447911123456` under a `+1` flag must not become `+144…` — a real US number belonging to
someone else. So a leading `+` wins over the picker: the person said which country themselves.

### 3. Pinned by tests, because this fails invisibly

`PhoneFieldSplitTests` holds six, all passing: ten digits under `+1` stores twelve characters; a
stored number edits back to ten; split-then-join is identity across `+1`, `+44`, `+34` and `+81`
(drift here would rewrite a contact every time the form opened); a typed `+` beats the picker; empty
stays empty; and a bare number stored before `J137` still edits sensibly.

### 4. Not the cause of the missing text

Worth repeating, because it would be easy to assume otherwise: storage was wrong, the send was right.
`VerifyValueView` always sent `Discovery.normalize(value)`, never the stored field, so Telnyx received
the full `+1` number every time. The undelivered code is still `J136`'s question, and still waiting on
a delivery receipt.

---

## J140 — Avatars pick from a palette, not from every hue there is

Don, 4 Sep 2026, photographing the top of a conversation: *"No pink LGBT buttons please."*

The circles were contact avatars, and the colour came from `Double(abs(seed.hashValue) % 360) / 360`
— **the whole colour wheel**. Magenta and pink came up as readily as anything else, so the one rule
this app's palette has (`AppTheme`: one hue, garnet, and nothing that lightens or drifts into pink)
was being ignored in the single place that generated colour at runtime.

Replaced with a curated ten: `12, 28, 42, 88, 128, 158, 186, 205, 222, 238` — earth, moss, teal,
steel. The magenta arc, roughly 260–350°, does not appear. Ten is ample to tell a contact list apart,
and every one sits quietly under white initials.

### The second bug in the same line

`String.hashValue` is **seeded randomly per process**, so *"the same person keeps the same colour"*
was quietly false — every contact was recoloured on each launch. Now FNV-1a, which is stable, which
was the entire requirement. Android's `hashCode()` was already stable but had the same full-wheel
problem; both `ContactEditScreen` and `ProfileSheet` now share the constrained palette.

---

## J141 — A safety number that disagrees means a stale card, not a broken hash

Don, 4 Sep 2026: *"She scanned my QR code but our numbers do not match."* Then, decisively: *"When I
scanned her code though the numbers do match, so I joined her."*

### 1. The maths is not the problem, and that is checkable

`safety_number` sorts the two keys before hashing, so it is symmetric by construction, and Rust tests
that. What Rust cannot cover is the **bridge** — whether Swift hands the keys across FFI in the order
it believes. `SafetyNumberSymmetryTests` now covers it: same digits either way round across three key
pairs, different peer gives different digits, eight groups of five zero-padded, wrong-length keys
refused rather than padded. All pass.

So no direction-dependent bug exists. If two devices show different numbers, **they are holding
different keys.**

### 2. How that happens, and it is in `MessageStore` in writing

The merge that prevents duplicate contacts keys on `peerPublicKey`:

```swift
if !contact.peerPublicKey.isEmpty, !contact.isSelf,
   let index = contacts.firstIndex(where: { $0.peerPublicKey == contact.peerPublicKey })
```

**A peer whose identity key has changed does not match, so it is not merged — it becomes a second
card.** An identity is regenerated by deleting and reinstalling the app, or by pairing under a
different profile. After a day of repeated installs that is not a rare event, it is the expected one.

The old card keeps the old key and therefore renders a safety number that can never match. It is not
stale-looking in any way: same name, same avatar, a number in the same shape.

### 3. Which is exactly the asymmetry

She scanned his QR; his device completed and filed the new keys — but the card he opened was the
earlier one, from a previous identity. When he scanned her code he was carried into the freshly
written card, and it matched.

### 4. What is actually wrong here, and it is a product bug

Not the hash. **Two cards for one person can coexist with nothing to tell them apart, and a safety
number carries no indication of which pairing it belongs to.** Two honest people comparing digits are
told they may have been attacked, and the app offers them no way to see why.

Worth fixing, and not yet fixed: merge on a shared verified value when the key has changed rather
than on the key alone, or at minimum mark a card whose pairing has been superseded.

---

## J142 — Showing your code from inside a contact made a second contact

Don, 4 Sep 2026: *"Even though we tried to link from within my contact, it created a new contact after
doing the verification step, and the old contact from where she started the process remained
unverified. And why do those two codes look different even though when it was verified on screen the
codes matched?"*

### 1. Two different numbers, which is the first half of the answer

They are not the same value and never were:

- The **confirmation code**, shown on both screens *during* pairing, comes from the handshake session.
  It matching proves the exchange was clean, and it was.
- The **safety number**, on the contact card, comes from the two long-term identity keys. It answers a
  different question: *does the card in front of me hold your real key?*

So "the codes matched but the numbers differ" is not a contradiction. It says the handshake was fine
and **the card being looked at is not the one that handshake wrote.**

### 2. Which it was not, because iOS wrote a different card

`saveContact` — the **scanning** half — has honoured `existingContactID` since `J30`: pairing begun
from a row attaches to that row. The **displaying** half never did. It called `store.addContact`
unconditionally, so showing your code from inside a contact and having them scan it produced a
*second* card holding the keys, while the card the pairing started from stayed **Unverified** for
ever, with nothing to distinguish the two.

Android had this right in both halves (`attachPairing` on either path). This was iOS only, and only
on the side that displays.

### 3. Why it is worse than untidy

The two cards then hold different keys, so the safety numbers on the two phones disagree — `J141`.
Two honest people compare digits, find they differ, and are told by every piece of security advice
ever written that they may have been intercepted. The cause was a missing `if`.

Fixed: the inbound path now calls `completePairing(contactID:)` when the sheet was opened from a row,
and the name field is prefilled from that row rather than asking again — being asked to name someone
already named is how one person ends up with "Lusmar" and "lusmar".

### 4. Still open

`J141` §4 stands: nothing yet detects the duplicate cards already created on these two phones. Delete
the stale one by hand for now.

---

## J143 — The confirmation code rotated mid-comparison and called it an attack

Don, 4 Sep 2026: *"Scanning the first time, her to me, the numbers didn't match so we didn't confirm.
Second time, me scanning her, they matched."*

They did the right thing. The app was wrong.

### 1. What happened

`confirmation_code` is bucketed into **30-second windows** — deliberately, and for a good reason:
binding the time bucket bounds an attacker to 30 seconds of grinding, and binding the ephemeral
shared secret is what makes the code impossible to precompute. None of that is in question.

What was missing is that **two phones in different buckets show different digits**, and nothing
stopped that. A pair who start reading at second 28 finish in the next bucket. They see a genuine
mismatch. And iOS then told them, in as many words:

> *"If they don't, someone is in the middle — don't save this contact."*

So an honest pairing is accused, and the users correctly abandon it. Reading twelve digits aloud and
being answered takes a few seconds against a thirty-second window; this was not unlucky, it was
routine.

### 2. The platforms disagreed about the advice

Android already said the right thing — *"It changes every 30 seconds — that's expected. If the two
phones disagree, wait for the next one before deciding."* iOS said the opposite. Don was on iOS.

But the Android wording was not a fix either. **Advice does not prevent the comparison, it only
excuses it afterwards**, and by then two people have already decided they were attacked.

### 3. The fix: withhold the digits near the boundary

For the last **six seconds** of every bucket, both platforms now show a countdown instead of a code,
and both confirm buttons are disabled. If both phones are showing digits at all, they are in the same
bucket — so a mismatch means what the screen says it means.

Six seconds is enough to read twelve digits and be answered. It costs a fifth of each window, which
is the right price for an alarm that can be trusted. **No security parameter changed**: the bucket is
still 30 seconds, the code is still bound to the ephemeral secret.

`ConfirmationWindowTests` pins the property directly: across two full buckets, any two moments that
both show digits and are within the blackout of each other fall in the same bucket. Without the
blackout that fails immediately — seconds 29 and 30 are one apart, in different buckets, and both
used to show digits.

### 4. What this cost

Three of the four pairing problems reported today were this and `J142` compounding: a false mismatch
on the first attempt, then a second attempt that worked but wrote a duplicate card, whose safety
number then disagreed with the original — `J141`. One missing `if` and one missing boundary guard
produced what looked like a broken cryptosystem.

---

## J144 — A blank screenshot has to say why it is blank

Don, 4 Sep 2026: *"She tried to screen capture and got a blank screen, which is OK, but it should
have displayed a message or something explaining the no-screen-cap rule for that page — assuming
that was us blocking the screen capture."*

It was us. `ScreenCaptureShield` (`J70`) hosts the interface inside a secure text field's canvas
layer, which iOS's compositor blanks in screenshots, recordings and the app switcher. It is **on by
default** on both platforms, so a new tester meets it in their first minute.

### Why it needed saying

iOS cannot refuse a screenshot; it can only hand back a blank one. A blank image with no
explanation does not read as *private* — it reads as *broken*, and a tester who thinks the app is
broken stops trusting what it shows them. The system only tells us **after** the capture
(`userDidTakeScreenshotNotification`), which is the earliest we can say anything, so that is when
it is said: an alert naming what happened and where to turn it off.

Android needs no equivalent: `FLAG_SECURE` makes the *system* refuse the capture and show its own
"can't take screenshot due to security policy" toast, so the user is already told.

### What this does not decide

Whether the shield should be on everywhere, on by default, or only on the screens that carry
something worth shielding. Don, minutes later: *"decide with intent what screen captures we want to
or should block within this app."* That is `J145`, and it is his call — this entry only makes the
current behaviour honest.

---

## J145 — Which screens block capture, decided on purpose

Don, 4 Sep 2026: *"Decide with intent what screen captures we want to or should block within this
app."*

`J70` already decided this, from Don's own WhatsApp screenshot: **per screen, not app-wide**, and
**an explanation, not a black rectangle**. `J70b` then built the opposite — the whole app inside one
shield, blank, on by default — because it was the cheapest thing that worked. The result was a
tester's first screenshot coming back empty with no explanation (`J144`). This entry does what `J70`
said to do.

### 1. The test for blocking a screen

Blocking is worth its cost only where a capture would carry something **that identifies a person or
unlocks a conversation**, and where the capture is likelier to be a leak than a legitimate use. Two
questions per screen:

- *What would be in the picture?* A key, a code, a number, a face, a message — or a menu.
- *Who takes it?* The owner sharing a bug report, or someone with the unlocked phone in their hand.

Blocking a screen with nothing in it protects nobody and teaches the user the app is broken.

### 2. The policy

| Screen | Block? | What the capture would carry |
|---|---|---|
| **Conversation** | **Yes** | The messages. This is the reason the feature exists. |
| **Photo / attachment viewer** | **Yes** | The picture itself, and view-once has no meaning without it. |
| **Pairing — my QR / code** | **Yes** | A pairing code is single-use and rotates; a picture of it is a pairing attempt anyone can start. |
| **Pairing — confirmation code** | **Yes** | Twelve digits that, photographed beside the other phone's, prove nothing about *this* exchange. |
| **Contact card** | **Yes** | Numbers, emails, the safety number, the photo — the whole identity, on one screen. WhatsApp blocks exactly this one. |
| **Messages list** | **No** | Names and previews. Worth a lock, not a shield; blocking it blocks the screenshot every bug report starts with. |
| **Contacts list** | **No** | Names. Same reasoning. |
| **Settings, Help, Profile sheet, Requests** | **No** | Nothing a stranger could use. |
| **Verify sheet** | No | The number being verified, which the user just typed themselves. |
| **App switcher snapshot** | **Yes, always** | Whatever was on screen when the app was left — the one capture the user never chose to take. |

**Default: on**, for the screens marked yes. The setting stays one switch — *Block screenshots* —
because a per-screen menu is `J67`'s prose problem in another form, and nobody will curate it.

### 3. What the capture shows instead of black

`J70` §1: *"you control what appears in the capture, not merely that it is blank."* The shielded
screens get a second layer **outside** the secure canvas carrying one line — *Channel blocks
screenshots of this screen* — so the image that leaves the phone explains itself, and the `J144`
alert becomes a courtesy rather than the only clue.

### 4. Why not keep the whole app shielded and be done

Because it makes the feature look like a fault. The first thing every tester does is screenshot a
problem to send it in; if that comes back blank, they conclude the app is broken, not private, and
they stop trusting what it shows them — which is the exact opposite of what a privacy control is
for. The cost of the per-screen version is one wrapper per sensitive screen and an honest line of
text. That is cheap.

### 5. Awaiting Don

The table is a recommendation. It is his call whether the Messages and Contacts lists should also
be shielded — the argument for is that names alone are sensitive to some users; the argument against
is §4.

---

## J146 — Grok's pairing review, verified finding by finding

Don, 4 Sep 2026: *"Have Grok review this whole QR code process too."* Its verdict is in
`PAIRING-REVIEW-2.md`, verbatim. Nothing in it was taken on trust; each claim was read against the
code, and this is what held.

**Verdict on the core: sound.** The Rust handshake — STS-style X25519 + Ed25519, a fixed 182-byte
transcript with a compile-time length assertion, reflection and contributory-DH checks, generation
bounding, zeroisation on drop — drew no findings. Safety numbers are stable either way round. Stale
flights are scoped by the per-attempt nonce. `J141`–`J143` are confirmed fixed.

### 1. Medium-High, confirmed and fixed: the public slot let a stranger end the attempt

Anyone who saw the QR can compute the inbound slot and deposit a well-formed first flight. `handle`
answered every such flight — so two scanners, or a scanner and a stranger, spawned two responder
sessions racing for one confirm. Worse, a first flight whose sender never sent flight 3 hit the
timeout and set `.failed`, which ended the attempt: **park one offer in the slot and walk away, and
the honest scanner arrives to a code already marked failed.**

Fixed on both platforms: one offer is negotiated at a time (iOS gains a `negotiating` guard;
Android's linear coroutine already had that property), and a timed-out offer is forgotten rather
than fatal — its half-built session freed, the listener back to waiting, the code's own expiry the
only thing that ends the wait.

What is *not* fixable locally, and is by design: a stranger who completes the exchange before the
friend does. The first flight is unauthenticated on purpose (`handshake.rs` says so), and the spoken
comparison is the root of trust — the stranger's code cannot match the number on the friend's phone.
The inbound sheet now says the one sentence that makes that true in practice: *"Add them only if the
person with you sees this same number."*

### 2. Medium, confirmed and accepted: clock skew beyond the blackout

`J143`'s six-second blackout is local to each phone. Two phones more than six seconds apart can still
straddle a bucket. Phones keep NTP time and skew is normally under a second, so this is rare — but
it is real, and the fix is a wire change (carry the scanner's clock in flight 1 so the displayer can
measure the skew and warn). Deferred; recorded here so it is not forgotten.

### 3. Medium, on the time bucket itself: a fair point, not a flaw

Grok is right that the ephemeral secret alone kills precomputation and the bucket only bounds
*post-exchange* grinding, buying a shorter spoken code at the price of the clock dependency. That is
the trade `handshake.rs` documents. Twelve rotating digits versus roughly fourteen fixed ones — the
shorter code read aloud was chosen on purpose. Left as is.

### 4. Low, confirmed and fixed: "Numbers Match" with no numbers

On the identity-keys-only path there is no confirmation code, and the button still read *Numbers
Match — Add Contact*: a lie the user was asked to tell. Both platforms now say **Add Without
Verifying** there.

### 5. Low, confirmed and fixed: the handoff decided by name

`saveContact` asked whether the peer had paired by looking up a contact **by display name**. Two
contacts can share a name and either can be renamed; both made it answer about the wrong person.
Now by public key on both platforms — Android's `onAdded` callback carries the key beside the name.

### 6. What this cost

Under a nickel of Grok, and one Medium-High that a human reviewer would have needed the field test
to find. The two Lows are the kind that survive because nobody reads a button label twice.

---

## J147 — Profiles are independent outward; one exposed value per phone; a linked device mirrors a profile or the whole account

Don, 4 Sep 2026: *"The profiles are separate identities as far as the world is concerned, so should look
and be independent of the others. But we cannot have two profiles use the same incoming ID, as then a
message would not know to what profile it should be delivered — so if you expose one number in profile
1 you can't expose that same number to the world in any other profile on that device. If you link to
another device with QR-code verify like WhatsApp does, you can appear that profile on another device,
or optionally have all your profiles — your entire account — mirror on another device."*

Three rules. Two were already the design and are enforced; the third closes a question `J20` left open.

### 1. Independent outward — already so (`J65`)

Each profile has its own identity key, its own mailbox tokens, its own `notifyId` (`J130` §4), its own
contact store. The relay cannot relate two profiles on one phone except through the push token they
must share, which is exactly the case `J130` documents. A contact of one profile learns nothing about
the others.

### 2. One exposed value per phone — already enforced (`J94`)

A number or email presented for discovery resolves to **one** slot, and a slot has to belong to one
identity or an invitation left there cannot be delivered. So `claimedElsewhere` checks every other
profile on the device, and the **Find me by** tick on a value another profile already presents is a
dead box, on both platforms. Don's statement is the reason that code exists, in his own words.

This is about **your own** card, not contacts. The second "Lusmar" card he found in another profile
is a contact, and two profiles may each know the same person; that card is simply not paired *in that
profile*, which is correct and, per `J146`'s note, worth saying more plainly on the card.

### 3. A linked device — decided: the WhatsApp model, per profile or whole account

`J20` set out three shapes for a second device: **A**, each device its own identity that mirrors
over radio; **B**, copy the private key; **C**, a primary identity signing per-device keys — Signal's
model, and *"the only option that gives true multi-device"*. It ended: *"if C is ever wanted, decide
before v1 ships."*

Don has decided. *"Link to another device with QR-code verify like WhatsApp does"* — a device that
**appears as the same profile**, not as a new contact that happens to be yours — is **C**. And the
unit of linking is his: **one profile, or all of them**. The whole-account form is the same mechanism
applied to every profile at once; nothing new is needed for it beyond a picker.

What that commits us to, stated now rather than discovered:

- **Identity gains a device layer.** A profile's long-term key signs a per-device key; contacts learn
  the device keys under it, and a message is sealed to every device of the profile. That is a
  protocol change to the identity layer — `J16`'s one hard migration — and it is now decided *before*
  v1, which is the whole point of `J20`'s warning.
- **Linking is a pairing ceremony between your own devices**, QR and the same spoken code, so there
  is no second security path to get wrong (`J20` said this too; it survives the change of model).
- **Unlinking must revoke**, and contacts must learn it, or a lost tablet reads for ever. Signal
  handles this with a signed device list; so will we.
- **`J94`'s rule extends across linked devices**: an exposed value belongs to a profile, and the
  profile now spans devices, so it is still one slot, one identity. Nothing changes for discovery.

Cost: this is the largest remaining protocol item. Its place in the order is after push and the
verification routes Don is testing now, and before anything that would make identity harder to
change. It is *decided*, not scheduled.

---

## J148 — She receives him, he receives nothing: a re-pair that silently kept the old keys

Don, 4 Sep 2026: *"Lusmar is receiving my messages via internet but I'm not receiving anything from
her."* Then: *"But the safety numbers show unmatched on my screen."*

Those two facts are one fact.

### 1. What the relay poller actually does

`ServerTransport.collectOnce` derives its inbound mailbox tokens from **each contact's `keys`** —
`myDestinationTokens(for: contact.keys, epoch:)` — and polls every one. The keys are the pairing. So
if his card for her holds the keys of an *earlier* pairing while her card for him holds the *latest*,
she deposits to a mailbox derived from the new keys and he polls one derived from the old. He never
sees her. And the safety number on his card, computed from the old key, cannot match hers.

The other direction working is the same fault seen from her side: his messages are sealed with the
old keys, and she can read them because her phone still has the old pairing too — `J142` reached her
only in 2.027, so she almost certainly has two cards for him, and it is the older one that lights up.

### 2. Why the re-pair did not fix it

`completePairing(contactID:)` — the attach that `J142` routes a from-inside-a-contact pairing to —
began:

```swift
guard let index = …, !contacts[index].isPaired else { return }
```

**Any re-pair of an already-paired card was a silent no-op.** Android's `attachPairing` had the same
rule (`if (existing.keys != null) return false`), with the reason written beside it: so a double tap
cannot invalidate a working conversation. Reasonable — and it meant that the one re-pair that mattered,
after her identity had changed, left his card exactly as it was. Both phones reported success. Neither
was wrong to.

### 3. The rule now

Refuse a re-pair **only when it brings the same key** — that is the double tap. A *different* key was
just confirmed aloud by two people who did it on purpose, and it replaces the old one. Both platforms.

### 4. Tonight, without waiting for a build

Delete his Lusmar card and pair once more from scratch; the new card takes the new keys. Or she deletes
her older card for him — but his is the one polling the wrong mailbox, so his is the one to reset.

### 5. What the last four entries add up to

`J141` (why numbers disagree), `J142` (the displaying half made a second card), `J143` (the code
rotated mid-comparison), and this — a re-pair that could not replace keys — are one afternoon's
field test against a pairing flow that had only ever been exercised between two emulators that never
changed identity. Every one of them is a state that a fresh pair of phones never enters.

---

## J149 — An invitation from someone you already have a card for is the re-pair, not noise

Don, 4 Sep 2026, after `J148`: *"I deleted her contact on my phone and created a new one, and then
sent a message based on her email that she is showing and has verified."* Her phone would have thrown
it away.

### 1. The line

`collectDiscovery` decided an invitation was *already* handled if any of three things held: the
envelope had been seen, an identical request was pending — **or a contact already held the sender's
key.** The third was meant to stop a paired contact re-requesting you. It also meant that the one
person whose invitation you most need to see — a contact whose phone now holds stale keys for you and
is trying to get fresh ones — was dropped silently. No request, no notification, and the sender waits
on an answer that was never going to come. Both platforms.

### 2. Why it matters more after `J148`

`J148` fixed the re-pair when the two people are together. The email or number route is the re-pair
when they are not, and this line closed it. The sender's phone says *"Invitation left"* and means it;
the recipient's phone collects it, marks it seen, and discards it.

### 3. The rule now

Duplicate **envelopes** are still deduplicated. A known **sender** is surfaced as a request like any
other — named, because the card already has a name — and a person decides. Not auto-accepted: that
would replace keys with nobody in the loop, which is the exact thing the double-tap guard exists to
prevent.

### 4. Tonight, on the build she has

Her phone polls her verified slots once a minute **while the app is open** (`J135`: no background
polling on iOS). So: she deletes her card for Don, opens Channel, and leaves it open for a minute.
With no card holding his key, `already` is false and the request appears; she accepts; the numbers
are compared over another channel (`J129`) and confirmed. His side is already listening and survives
relaunch (`resumeInvitationListeners`).

---

## J150 — An unverified number is shown to nobody

Don, 4 Sep 2026: *"Her number should not be exposed to anyone until it is verified, because it could be
somebody else's number that she is stealing. So the only thing her account should be showing is the
verified codes, if they're working, and that email which she has verified."*

Already so, and checked at each layer rather than assumed:

- **Contacts see no numbers at all.** The card a profile sends to its paired contacts is
  `ContactCardWire(name, stock, jpeg)` — a name and a picture. Phones and emails are never in it,
  verified or not, on either platform. A contact who wants your number asks you.
- **Discovery answers only for proven values.** `collectDiscovery` skips any value without a
  collection capability, and a capability exists only once the code came back (`J105`). An
  unverified number's slot is never polled, so an invitation left there reaches nobody.
- **The tick cannot be set first.** "Find me by" is not a toggle until the value is verified; in its
  place is the Verify button (`J101`). Publishing is a consequence of proof, not a request for it.

The two numbers Don saw under her name on his phone were on a card **he** created — his own contact
entry, never anything her phone sent. The distinction is the one `J147` §2 draws: the rule governs
your own card, and a contact is somebody else's guess about you.

---

## J151 — The keyboard covered the field being typed into (Android)

Don, 4 Sep 2026: *"On Android I'm trying to add Lusmar's email and when I do the keyboard opens up and
covers up the email entry field so I can't see what I'm typing."*

The manifest already says `adjustResize`, and the comment beside it insists it is load-bearing. It
is — but the app draws edge-to-edge (`enableEdgeToEdge`), which hands the keyboard inset to Compose
to consume, and the contact editor's scrolling column never did. So the window resized and the
content did not: the lower rows stayed under the keyboard. `ConversationScreen` had `imePadding()`
from the start, which is why typing a message never showed this.

Fixed with `imePadding()` on the editor's column and on the pairing screen's Enter Code tab — the
only other scrolling screen with a field low enough to be hidden. The scroll container shrinks and
the focused field is brought into view. iOS's `Form` does this on its own.

Until the build lands: press Back once to drop the keyboard and read what was typed.

---

## J152 — Android's New message said "pair before you can send" and offered nothing

Don, 4 Sep 2026, on Android: *"When I tried to send a message to her, I get this error because clearly
it's not verified, but I should be able to send based on her email, which she has already verified
and made available."*

Half of that is the design and half is a gap.

**The design:** a message has to be sealed to a key, and an unpaired contact has no key — so there
is genuinely nobody to write to yet, whichever of her values are verified. That is `J30`, and it is
not negotiable: a thread whose every message stayed queued for ever would look exactly like the app
being broken.

**The gap:** her verified email is precisely the place to leave an **invitation**, and `J119` made
that the answer to composing to an unpaired contact — *"Reaching them is the step that finds out
whether they are there at all."* iOS has done that from the New message picker ever since. Android
greyed the row, said *"Not paired yet – pair before you can send"*, and offered nothing. The store had
`inviteEverywhere` and `invite` — and **no screen on Android called either**. The parity was done at
the model layer and never reached the UI.

Now: an unpaired contact with at least one reachable value is tappable, and tapping asks *"Reach
Lusmar?"* with the same choices as iOS — every value at once (`J127`), or one — then says
*"Invitation left"* in the same words, or *"Could not send that invitation"* when nothing was. A
contact with no reachable value stays greyed, and the row says what to do instead.

Still not on Android: the Reach offer from the **contact card** (`J125`). Same store call; a
follow-up, not a blocker — New message is where Don went.

---

## J153 — Two follow-ups from the 5 Sep field test, recorded before Don sleeps

**a. A pairing begun from the Pairing tab still makes a second card.** Don paired his Android and
iPhone by QR; the spoken codes matched; the safety numbers on the two cards do not. Both bridges call
the same sorted Rust function, so the numbers differ because the keys differ — and they differ
because a pairing started from the Pairing *tab* has no `existingContactID`, so the displaying side
runs `addContact`, whose merge is by **key**. A new key never matches the old card; a second card is
created; the old one keeps stale keys and its number can never agree. `J141` §4 deferred this pending
merge-versus-mark. Third occurrence; decide and build: **when a pairing completes with no card to
attach to and a contact with the same display name exists, attach to it** (`J148` semantics) rather
than add — and say so on the card. Two different people with one name on one phone is the risk, and
the safety-number check is what catches a wrong merge.

**b. Android's confirmation screen has no way back.** After "Numbers Match" the sheet shows no
navigation; Don used the system Back button. The screen should dismiss itself on success, the way iOS
does, or carry a Done.

**c. Bluetooth one way, Internet the other — expected.** `J1` addendum: a backgrounded iPhone cannot
be discovered by Android but can scan, so iPhone-initiated sends find the Android radio and
Android-initiated sends fall to the relay. The letters are reporting the truth.

**d. The iPhone thread shows no per-message channel.** Android marks each bubble with the letter of
the path it took (`B`, `I`); iOS shows only the delivery ticks. `J65` says the two apps are
identical, and `J65c` put the letter on every conversation *row* — the per-bubble letter reached
Android and not iOS. Same badge, same position, on iOS.

**e. iPad: the Settings banner is black, not garnet.** Don, 5 Sep 2026. The phone's bar is painted
by `channelBrandBar`; on iPad the Settings page is most likely presented in a split-view column whose
toolbar never receives that modifier, so it falls back to the system's black. Verify on the iPad
simulator and apply the same bar to every column that shows a title.

**f. Toggling "Block screenshots" off kicks you back to the Settings root.** Don, 5 Sep 2026.
`captureShielded(enabled)` returns either `ScreenCaptureShield(content: self)` or `self` — two
different view types at the root — so flipping the setting changes the root view's identity, SwiftUI
rebuilds the `NavigationStack`, and the pushed "Screen & lock" page is gone. Fix: a stable container
at the root that always hosts the content and only *toggles* the secure layer, so the navigation
state survives the switch. Check whether the same happens on Android (`FLAG_SECURE` is applied to the
window, not the tree, so probably not).

**g. A request says where it reached you, never who sent it.** Don, 5 Sep 2026: *"Found two
incoming requests and it notes the email they connected to me via and asks me to enter a name for
them, yet there is nothing in the request that tells me who is requesting, so how am I supposed to
know what name to give them — or if it's really them if I knew?"*

Two questions, two answers. **Who:** the invitation is `[version][identity][nonce]` — `J68` §5 says
it cannot be *encrypted* to the recipient, but nothing stops the sender putting a **display name** in
it in the clear. A name is world-facing the moment a value is published (`J108`), so this costs the
sender nothing they had not already spent. Payload v2 carries an optional name; the request reads
*"Someone calling themselves **Lusmar** reached you at lusmarsi@gmail.com"*, and the name field is
prefilled from it. **Whether it is really them:** the request can never prove that, and must not
pretend to — a name in the clear is a claim. Accepting exchanges keys and lands at **Not verified**;
the safety-number comparison over another channel (`J129`) is what answers the question, and the
request copy should say exactly that: *accept, then compare numbers before you trust it*. Two
requests appeared because two invitations were left (`J127` reaches every value at once, and she has
two cards for him); dedupe by sender key so one person is one request.

**h. Accepting a request blocks on the other phone.** Don, 5 Sep 2026: *"I hit accept and it says
working — but at what? The other side could be offline for hours and there is now no escape from this
screen, so the handshake has to be working asynchronously, of course."* It is asynchronous on the
wire — three flights through relay slots — but `acceptRequest` runs the scanner path *inline* and
`awaitReply` holds the sheet for up to 90 seconds. The sender side already has the right shape:
`pendingInvitations` persisted and `resumeInvitationListeners` at launch. Accept should do the same:
deposit flight 1, record a pending acceptance, dismiss, and finish whenever flight 2 arrives — with
the request row reading *"Waiting for them"* and the card appearing on completion. Nobody should be
held on a screen for a phone that is off.

Addendum, minutes later: *"On the second request I hit accept without entering a name and it is
still stuck — let me skip the naming at least; they're both probably from the iPad."* Two more rules
for the same fix: **a name is never required to accept** — prefill from the claimed name when there
is one, fall back to the value they reached you at, and let the person rename later, exactly as a
paired contact can be renamed; and **the second request from the same sender key is the same
request**, shown once. Both requests were his own iPad reaching him at two of his values (`J127`).

---

## J154 — The overnight batch: everything from `J153` built

Don, 5 Sep 2026: *"Do all that and do not stop until all done and pushed to me. Not just 1–3 —
everything."* This entry is the first batch; the ones after it continue down the same list.

- **a. Pairing from the Pairing tab no longer makes a second card.** `attachOrAdd` on both stores:
  a completed pairing with nowhere to attach lands on a contact of the same name (case-insensitive,
  trimmed) whose key differs, replacing its keys under `J148`'s rule. Used at all four pairing sites
  and by accepted requests. The accepted risk — two people with one name on one phone — is caught by
  the safety-number comparison, which is the check people actually run.
- **b. Android's confirm screen closes itself** once an inbound pairing has completed both halves.
- **d. iOS bubbles carry the channel mark** Android had. This supersedes `J36`'s "no letters there"
  for the bubble: Don, comparing the two phones side by side, asked for the same mark. The tick
  colour still carries the path for outbound.
- **e. iPad: the tab bar is painted garnet** in the regular width class, where it sits above the
  navigation bar and had never been painted. The phone's bottom bar is untouched.
- **f. Toggling the screenshot shield no longer resets navigation.** `ScreenCaptureShield` is one
  view with an `enabled` property; the hosted content is re-parented between the secure canvas and
  the plain container instead of the root view changing type.
- **g. A request says who.** Payload v2 = `[0x02][key][nonce][len][name ≤ 24 bytes]`, sent for
  invitations only (QR stays v1); both decoders accept both. The row reads *"Someone calling
  themselves Lusmar"*, the name field is prefilled and optional, and requests are one per sender key.
- **h. Accept never waits.** `pendingAcceptances` persisted; the exchange runs behind the list,
  resumes at launch, expires with the invitation; the Requests screen shows *Waiting for them* with
  Cancel.
- **h2. Android's contact card offers Reach**, through one shared `rememberReach` used by New
  message too, so the two cannot drift again.

---

## J145 — Built: per-screen capture shielding, with an explanation in the capture

The table in `J145` is now what ships. **Shielded:** the conversation, the attachment viewer, both
pairing screens, and the contact card. **Not shielded:** the Messages and Contacts lists, Settings,
Help, Requests, the verify sheet. **The app-switcher snapshot** is covered on iOS by a garnet curtain
whenever the scene is not active, and on Android by `FLAG_SECURE` being raised exactly while a
shielded screen is on top, which is what the snapshot shows.

**iOS says why.** `captureShielded` places a garnet layer carrying *"CHANNEL — Screenshots of this
screen are blocked to keep the conversation private"* **behind** the secure canvas: invisible on
screen, visible in the capture. The picture that leaves the phone explains itself. Android needs
none — the system refuses the capture and shows its own toast.

`J153e` (the iPad tab bar) is built but not seen on a device: the iPad simulator needs a permission
grant Don was not awake to give. The change is a `toolbarBackground` for `.tabBar` in the regular
width class only, so the phone is untouched either way.

## J155 — Push client halves shipped (2.035)

`J135` built the relay side; this is the phone side, both platforms.

1. **Registration.** iOS `PushRegistrar` + `PushAppDelegate` (the `J5`
   "never call `registerForRemoteNotifications`" rule is formally reversed
   here, for silent, content-free wakes only); Android `PushRegistrar` +
   `ChannelMessagingService` (FCM data-only). Each profile that has push on
   mints an opaque 32-hex `notifyId`, stored in its per-profile preferences,
   and the phone's one token is registered against every such id
   (`POST /v1/register?id=`). Opting a profile out deletes its id at the
   relay (`DELETE`) and forgets it locally, so the next card it sends
   carries none.
2. **Handing it to contacts.** `ContactCardWire.notify` (optional, appended
   per `J72a`) carries the sender's id; the receiver stores it as
   `Contact.peerNotifyId` and attaches it to every deposit for that contact
   as `?notify=` -- a query item, never inside the envelope. A contact that
   has push off simply never gets woken; nothing else changes.
3. **The switches (`J130` §3).** Notifications › *Wake me when mail arrives*
   (device-wide, default on) and, under it, *This profile: check only when
   I open* (per profile). The per-profile switch can only make a profile
   quieter. Both call `sync()` so the relay agrees within a second.
4. **Not yet:** a changed id is not pushed to existing contacts until the
   next card exchange (a contact-card resend on toggle is the obvious
   follow-up); iOS `aps-environment` is `development` in the entitlements
   file and Xcode rewrites it to `production` on archive.

## J156 — Voice verification built; the channel chooser (2.036)

`J131` §1-2, done. The verify sheet on both platforms asks *Text message* or
*Phone call* before it sends anything (phones only; email is email). A call
is placed by `verify.mts` through Telnyx Call Control (`POST /v2/calls`,
`TELNYX_CONNECTION_ID`, from `TELNYX_FROM`) with the code in `client_state`;
`call.mts` (`/v1/call`) answers the `call.answered` event by speaking the
digits twice, slowly, in the phone's language, then hangs up on
`call.speak.ended`. The code is never in a URL and nothing about the call is
stored. Calls and texts share the monthly budget.

`J132`: the route that proved a value is kept beside the proof
(`verifiedVia`: sms / voice / email) and Android's *Verified* label says so
(*Verified by call*). iOS shows the tick only for now; the caption is a
follow-up. The proof format itself is unchanged, so the relay's checks are
untouched.

Portal side: Voice API application *Channel verification calls* → webhook
`https://channelmessenger.netlify.app/v1/call`, API v2; an outbound voice
profile attached; +1 708 395 8407 assigned to it. `TELNYX_PUBLIC_KEY`
(optional) turns on Ed25519 verification of the webhook.

## J157 — Help shipped (J106) and profile copy (J114), 2.036

**Help.** `docs/help/*.md` is the source -- eleven pages: getting started,
pairing, being found, reach, the relay, waking your phone, messages, privacy
on this device, settings, troubleshooting, and an index.
`scripts/build-help.py` renders them to self-contained HTML (style inlined,
no remote resource of any kind) into `app/ChannelMessenger/Help/` and
`android/app/src/main/assets/help/`; run it after editing a page. Each app
shows them in a fourth tab: iOS `HelpView` (WKWebView, JavaScript off,
non-persistent data store, only `file:` URLs inside the bundle are followed),
Android `HelpScreen` (WebView, JavaScript off, `blockNetworkLoads`, only
`file:///android_asset/help/` is followed). No reload gesture on either.
The `J115` page cycle is now four. English is the reference text and each
page says so; there are no translations yet.

Not done from `J106`: the help-version marker on a relay response and the
on-demand fetch from the website. Updates ride app releases, which is the
default `J106` settled on anyway; the fetch is an option to add, not a gap
in the default.

The internal documents (DECISIONS, MASTER-PLAN, LEGAL-RISK, TELNYX-10DLC …)
are **not** in the bundle. "The entire documentation" is read as the entire
*user* documentation: the internal files quote Don, name suppliers, and carry
the EIN, and none of that belongs on a stranger's phone.

**Profile copy.** New profile now asks *Start from* (Blank, or any existing
profile). Choosing one copies that profile's settings override only, with
`notifyId` cleared (`J130`) and the card prompt reset; the name field is
filled with the old name numbered (*Work 2*) and focused. Nothing
identity-shaped is copied, exactly as `J114` lists.

## J158 — The `+` sheet, hints, and *Send my location now* (2.037)

**`J116`.** iOS now has the `+` the Android composer already had, opening
the same labelled grid. Photos still goes through the system picker, out of
process. **Camera** is new on both: iOS `UIImagePickerController` in camera
mode; Android the `TakePicture` contract writing to a `FileProvider` URI in
the app's own cache (`${applicationId}.files`, `xml/file_paths.xml`), with
the CAMERA runtime request made at the tap. A capture takes the same
downscale-and-strip path as a picked photo and lands on the same preview
sheet; nothing is ever sent on capture.

**`J115` §5.** *Long press a row for options* appears once on the Messages
and Contacts lists, a second after arrival, for six seconds, and retires
itself the first time a row is long-pressed (`retiredHints`, device-level).
Show hints off means none of them, anywhere.

**`J120` §1, the first half.** *Send my location now* in the `+` sheet: one
fix, asked for at the tap (when-in-use on iOS; FINE/COARSE at runtime on
Android -- `ACCESS_FINE_LOCATION` lost its `maxSdkVersion=32`, which had
been there for Bluetooth scanning only), one message, nothing kept. It is
sent as a plain message -- `📍 My location: https://maps.google.com/?q=lat,lng`
-- so it is sealed like any other, needs no new wire type, and opens in
whichever maps app the other person has. Links in bubbles became tappable on
both platforms for this (`NSDataDetector` / `Patterns.WEB_URL`, no markdown,
nobody's words change).

Not yet from `J120`: live share for a fixed time, the inline map, and the
bearing arrow. Those are the second half and need the map libraries.

## J159 — Stores sealed at rest (J109 §2); app PIN and distress code (J122), 2.038

**At rest.** `conversations.json` and `profiles.json` are now AES-256-GCM
ciphertext (`CMS1` + nonce + body + tag) under a key the app never writes to
a file: iOS keeps it in the Keychain, this device only, after-first-unlock;
Android generates it inside the Android Keystore. A file without the magic
is a pre-`J159` plaintext store, read as before and rewritten sealed on the
next save, so nobody loses anything on update. Availability is unchanged --
released after first unlock -- so background delivery still works. Not yet
from `J109` §2: the opt-in *lock messages when the app is closed* tier
(user-authentication-bound key). Attachments and avatars keep their file
protection class for now; sealing them is the follow-up.

**The PIN.** Settings › Screen & lock › *Set an app PIN* (4-8 digits). With
one set, the lock screen shows the pad first and a face alone never opens
the app; with *Require unlock* also on, it is PIN then biometric, both.
Never stored: PBKDF2-HMAC-SHA256, 210,000 rounds, 16-byte salt, verifier in
the Keychain / app-private prefs. PBKDF2 stands in for the Argon2id `J122`
named -- neither platform has Argon2 without a dependency, and at this cost
over a short numeric code the difference is not the weak point. Three wrong
tries start a delay that doubles from five seconds to five minutes.

**The distress code.** A second code, must differ from the PIN. Entered on
the lock screen it shows exactly one small *Confirm*. Confirm destroys the
store key first (crypto-shredding, `J122` §3), then the identity secrets,
then every file, preference and cache the app owns, and ends the process --
the next launch is a first launch, and nothing on any screen says otherwise.
Best-effort relay confirm-delete is not attempted: a distress wipe must
never wait on a network, and the old tokens belong to nobody afterwards.

Per-profile PIN (`J122` §1's second bullet) is not built; the profile lock
still uses the device's biometric/credential prompt.

## J160 — Backup and restore (J110), 2.039

Settings › Backup, on both platforms. *Back up now* asks for a passphrase
(8+ characters, entered twice, with the warning that we cannot recover it)
and hands the sealed file to the system's own file picker -- iCloud Drive,
Google Drive, a folder, a computer; the user chooses, we run no service.
*Restore from a backup* opens the picker, asks for the passphrase, writes
everything back and ends the process, so the next launch is a clean start
on the restored data.

**Contents.** Every profile: id, name, identity secret (out of the Keychain
/ `EncryptedSharedPreferences`), the conversations store with
**disappearing and view-once messages removed**, avatars; plus the device
preferences and per-profile overrides. **Not included:** photos and other
attachments (size; said on the screen), the app PIN, notify ids (`J130`:
a restored phone registers afresh).

**Format.** `CMB1` + 16-byte salt + 12-byte nonce + AES-256-GCM(JSON)
under PBKDF2-HMAC-SHA256(passphrase, salt, 300,000). PBKDF2 for the same
reason as `J159`. The JSON is the same shape on both platforms, so a backup
made on one can in principle be restored on the other; the store JSON
inside is platform-specific today (`expiresAt` vs `expiresAtMillis`, and
so on), so cross-platform restore is not claimed until the stores agree.

The screen states what the destination learns: that a backup exists, its
size, and when it changed. `J110`'s point that restore is a pairing-grade
event stands: it runs behind the app lock, and `J98` linked devices, when
they exist, must be told.

## J161 — AI on the user's own account (J117 §2, J118), 2.040

Settings › AI (global): provider -- OpenAI, Anthropic, xAI -- model, and the
user's API key, kept in the Keychain / app-private prefs on this phone. The
relay never sees any of it. With a key saved, a ✦ button joins the composer.

Per use (`J118`): a sheet with the request, a switch *Include the last N
messages from this chat* (off by default), and -- when it is on -- the
exact lines that will leave, the other person's words included, said
plainly. Compose sends one request on the user's key; the reply is shown as
a suggestion and goes into the draft only on *Use this*. Nothing is ever
sent on its own: no suggested replies, no summaries, no background calls.

Text only for now; image generation (`J118`'s meme) is provider-specific
and comes when an attachment can be handed back into the preview sheet.
The card-resend gap from `J155` §4 is closed in the same build: a push
toggle changes the card's bytes, so `sendCardsWhereOwed()` runs on sync and
contacts get the new card over the next open path.

## J162 — Two ways to be woken: the sweep, and the moment mail lands (2.041)

Don, 5 Sep 2026: *"if push is available the phone should be getting auto every 15 min
silent notifies to wake it up and cause the phone to pull data from the server to check
for mail and if push is enabled on the phone config the server silent pushes should stop
for that device and the device should only pull when it receives a message waiting
notify event from the server"*.

This replaces `J130` §3's on/off with two modes, and changes the default.

- **Periodic (default).** Every profile that has not opted out registers its
  notify id with `mode: periodic`. A scheduled relay function
  (`wake-sweep.mts`, `*/15 * * * *`) sends every such id one content-free push
  whether or not anything is waiting; the phone polls exactly as it would on
  its own timer. On iOS that timer never fires for a closed app -- this does.
  Because the pushes are on a clock, their timing says nothing about any
  message, to Apple, Google, the relay's own logs, or anyone on the path.
- **The moment mail lands (the phone switch, off by default).** `mode: event`:
  `deposit.mts` wakes the id when an envelope arrives, and the sweep leaves
  it alone. Faster, at the cost that wake timing now matches mail timing.
- **Check only when I open** (per profile) is unchanged: no id registered.

Registrations older than 45 days are dropped by the sweep. Clients from
2.035--2.040 registered without a mode and are treated as `event` until
they update, when they re-register as `periodic`.

Cost: one push per registered id per 15 minutes -- 96 a day -- which is the
"costly method" Don named on 4 Sep and accepted; APNs and FCM charge nothing
for it, and the relay's cost is one scheduled invocation per quarter hour.

## J163 — iOS contacts "gone again" (5 Sep 2026): what was checked, what changed (2.042)

Don, 5 Sep: *"my contacts are gone again on ios ... but they're still there on the android."*

**Checked.** The simulator, updated over a Sep 4 store, showed the first-run
screen; its store had been set aside as `conversations.unreadable-…` on
4 Sep 19:01 by an intermediate build. A probe test decoding that file with
the current models passed every section (contacts, conversations,
discovery, invitations), so the current decoder is not the cause. The
`J159` sealing is the other candidate: a sealed file whose key cannot be
read looks exactly like an empty app, and nothing on screen said so.

**Changed, all iOS.**
1. `StoreCipher.key()` never mints a new key on a Keychain *error* -- only
   on *not found*. A second key would make every file sealed under the first
   unreadable for good. Reasons are logged.
2. A store that will not read now shows a banner on the Messages list
   (*nothing was changed; restart*) instead of an empty list.
3. `recoverAside()`: with no `conversations.json` but an `unreadable-*`
   sibling, the newest one is decoded and adopted if it decodes now.
4. Profile recovery picks the profile whose store was written last, not
   the first by name.
5. `Contact.peerNotifyId` was encoded but never decoded (custom `init(from:)`),
   so push ids were forgotten on every relaunch. Fixed.

The distress wipe and Backup restore were not involved (neither ran).

## J163 §3 — Found: the decoder, not the key (2.046)

Don's Storage page (5 Sep, 12:41 and 12:50) settled it. The sealing key was
available and every file opened; both profiles had a
`conversations.unreadable-1788610585.json` from 4 Sep 19:04, and the 407
profile's copy held the two contacts. *Use this copy* put it back twice,
and twice the launch set it aside again -- so the full decoder was
refusing a file the shallow peek could read.

**The bug.** `PersistedState` declared appended fields with defaults
(`var discoveryRequests: [DiscoveryRequest] = []` and so on) under the
belief that a default makes the key optional. It does not: Swift's
synthesized `init(from:)` calls `decode`, not `decodeIfPresent`, and a
store written before the field existed fails with `keyNotFound`. Every
appended field since discovery has carried this; each new one broke every
older store on first launch, set it aside as unreadable, and wrote a fresh
empty store -- which is exactly "my contacts are gone again", twice.

**The fix.** `PersistedState` has a hand-written `init(from:)` reading
every appended field with `decodeIfPresent`, proven against a 4 Sep store
in a test. On load, a live store with nobody but the self card beside a
set-aside copy that has contacts now adopts the copy on its own
(`adoptRicherAside`), keeping the empty one as `replaced-`. Android was
never affected: `Json { ignoreUnknownKeys = true }` plus Kotlin defaults
do what the Swift comment only claimed.

**Rule, added to `J72a`:** on iOS, appending a field to any persisted type
means adding a `decodeIfPresent` line to a hand-written decoder, or it is
not appended, it is redefined.

## J164 — Screenshot blocking: no switch, only where it means something (2.047)

Don, 5 Sep 2026: *"the ability to turn it on or off is kind of useless ... decide what, for
privacy, a user should not be able to screen capture and just block it for those page(s)."*

Right. The phone's owner is the only person who can capture that phone, and the block never
reached the other end of a conversation, so `J145`'s per-screen switch protected nobody from
anybody. Reversed:

- **Always shielded, no setting:** the view-once viewer (the sender's promise, kept by the
  recipient's phone), and every screen where a secret is typed -- the PIN pad and distress
  code, the backup passphrase, the AI key. The app-switcher card stays blank as before.
- **Never shielded:** conversations, contacts, pairing QR and safety numbers. Screenshotting
  your own chat is ordinary; the QR is public data; the privacy claim rests on what leaves the
  phone, not on stopping its owner photographing it.
- The *Block screenshots* switch is gone from Settings on both platforms. The preference field
  stays in the model, ignored, so older stores decode (`J163` §3).
- Help says plainly that nothing stops the other side.

## J165 — "Fetch new messages": Apple Mail's words (iOS 2.050, Android 2.048)

Don, 5 Sep 2026: *"this could be an option under the menu to choose pull frequency but
might want to rename that setting to something like Message Fetch - think what other apps do."*

Apple Mail's *Fetch New Data* is the page every iPhone owner has already read: **Push**, a
schedule, or **Manually**. So the wake switches leave Notifications and the *Checking* page
becomes **Fetch new messages**, opening with one choice: **Push** (`J162` event mode),
**Every 15 minutes** (the sweep, default), **Manually** (new: `fetchManual`, nothing registered
with Apple or Google at all). The per-profile opt-out stays beneath it, as *This profile:
manually*, under the This profile scope. The foreground cadence and quiet hours follow.

## J166 — Push / Fetch / On demand, with a fetch interval (iOS 2.051, Android 2.049)

Don, 5 Sep 2026: *"on demand, fetch (with frequency options) or push are the 3 choices"*, and,
on settings copy generally: *"you don't see apple's settings with cute names ... or long prose
about the function of the buttons ... when you over explain you just create more confusion when
the translations come in and if you need that much explain the buttons just were not well
designed or named."*

Two changes. **Fetch new messages** now reads exactly *Push* / *Fetch* / *On Demand*; with
Fetch chosen, a second row: *Every 15 Minutes* / *30 Minutes* / *Hourly*. The phone registers
its interval (`interval`), the sweep still runs every 15 minutes and simply skips an id whose
`lastWakeAt` is younger than its interval. The per-profile row is *This Profile: On Demand*.

**Settings copy.** Every helper line and section footer is gone from Settings on both
platforms, and rows are nouns: Reach, Lock, Hints, Face ID, App PIN, Distress Code, Merge
Profiles, Relay for Others, Read Receipts, Photos: Nearby Only, Quiet Hours (10 PM – 8 AM),
Sound, Translation, Back Up Now…, Restore…. Explanations live in Help. The verify sheet's
paragraph went the same way.

Also: iOS lists had no long-press menu at all, so the `J115` hint was lying on iOS. Conversation
rows now have Archive / Delete on long press, contact rows Block / Unblock, matching Android.

## J167 — One fetch question, not two (iOS 2.052, Android 2.050)

Don, 5 Sep 2026, with a photo of the Android page: *"The upper part and the bottom part are a bit
too redundant ... why we need two different frequencies."* The `J18` cadence list (every minute /
15 / 30 / hour / only when I tap) sat under the new Push / Fetch / On demand list and asked the
same thing again. Gone. The page is now: Push · Fetch (Every 15 Minutes / 30 Minutes / Hourly) ·
On Demand, then Quiet Hours. The while-open poll is derived: every minute for Push and Fetch, on
demand for On Demand. `Cadence` stays in the model because the transports read it.

## J168 — Three fixes from the two-phone test (iOS 2.053, Android 2.051)

1. **The mark lied.** Android showed **B** on outgoing while the iPhone received the same
   messages as **I**. `sendReportingPathway` reported the first rung that had a visible peer,
   and `J85` then also sent through the relay as insurance -- which is what actually arrived.
   A queued Bluetooth write is not a delivery. Now: when the relay backup went out too, the
   mark is I; B/W/L only when the radio stood alone. Both platforms.
2. **Android played no sound.** The `messages.default` channel had been minted by an earlier
   build and a channel's sound and importance are immutable once created, so today's code
   could not fix it in place. Channel family rotated to `msg2.*` (old family deleted),
   IMPORTANCE_HIGH with the chosen sound, lock-screen visibility public (the content is a fixed
   line), vibration on. Don's question answered: banner and lock screen are properties of the
   channel on Android, set once here and thereafter the user's in system settings.
3. **iOS message-requests row would not open**, even after the list gesture was removed. The
   row is now a Button presenting `RequestsView` in a sheet; a sheet cannot fail to present.

## J170 — iOS never used Bluetooth in the ladder (iOS 2.054, Android 2.053)

Don, 5 Sep 2026: *"on iphone when i open the contact only I is shown as available so the iphone
does not see the android having bt"* / *"but the android sees the iphone as having bt."* The
symmetry question ("does the A16 advertise?") turned out to be beside the point. `BLETransport`
on iOS never adopted `TransportControls`, and `PathwayTransport` skips any rung that is not one
-- for sending, for the channel bar, and for `checkNow`. So the iPhone has never sent a message
over Bluetooth and never lit **B**; every message left by relay. Android's `BleMessageTransport`
always conformed, which is why it could show B (and, per `J168`, why that B was an optimistic
mark backed by the relay).

Fixed on both sides, the same way:
1. `BLETransport` conforms to `TransportControls`: `visiblePeers` is every peer heard from in
   the last five minutes (`visibilityWindow`) plus every central subscribed to our notify
   characteristic; `checkNow` re-asserts scanning and advertising; `setConversationOpen` is a
   no-op. Scanning runs with duplicates off, so `visiblePeers` restarts the scan every 45 s to
   keep `lastSeen` fresh while a phone is still near.
2. **A phone that connects to us is a peer.** Subscribed centrals (iOS) / subscribed devices
   (Android) are in `visiblePeers`, so an Android that cannot advertise is still reachable from
   the iPhone through the notify mailbox it subscribed to. `lastSeen` is refreshed on connect,
   subscribe and inbound write, not only on discovery.
3. Android gains the same five-minute window; before this a peer stayed "visible" forever, up
   to the 200-peer cap.

The `J169` Reach line stays: it still tells whether *this* phone can be found, which matters for
the first contact and for relaying.

## J171 — A connected central must outlive its connection (iOS 2.055)

Don, 5 Sep 2026, on 2.054: *"Android shows BIR and iPhone shows only I."* `J170` removed a
central from `peers` the moment it unsubscribed. An Android encounter is connect → subscribe →
write → 1.5 s → disconnect, then a 60 s cooldown, so the iPhone saw it for two seconds a minute
and the five-second reachability poll almost never did. Now an unsubscribed central stays a peer
and ages out through the five-minute window like everything else; envelopes queued for it wait
for its next subscription, which is the mailbox shape `J6`/`J7` already describe.

## J172 — The photo preview sheet lost the race with the picker (iOS 2.056)

Don, 5 Sep 2026: *"I can send a photo from android but cannot send one from iPhone. I can select
one from iPhone, but it never shows the photo in transit or with the progress bar."* The picker
and the camera are presented as sheets; the preview (`PhotoPreviewSheet`) is another sheet,
presented the moment `ImagePreparation` finishes -- which is while the picker is still animating
away. SwiftUI drops a presentation requested during another's dismissal without an error, so the
prepared picture sat in `pendingPhoto` with nothing on screen. `stagePhoto` now waits until the
picker, camera and attach sheet are all down, plus half a second for the animation, before it
sets `pendingPhoto`. Android's `PhotoPreviewDialog` never had the problem: a Compose dialog is
not a presentation.

## J173 — Location links: Send As and Open With (iOS 2.057, Android 2.054)

Don, 5 Sep 2026, after sending a location both ways: *"They both appear as Google Maps links ...
you should have two options really, one is what sort of location you send and the second is what
kind of location you display when you receive one ... both links send latitude longitude,
therefore you could receive a Google Maps location and display it as an Apple Maps location."*

Settings › Location, on both platforms:
- **Send As**: Apple Maps, Google Maps, Waze. The link `Send my location` writes.
- **Open With**: the app a received location opens in. Apple Maps, Google Maps, Waze on iOS;
  Google Maps, Waze on Android (Apple has no Android app).

Both default to Google Maps. The rewrite happens on tap, not on the wire: `LocationLink` reads the
coordinates out of any Apple, Google, Waze or `geo:` link (`ll`, `q`, `query`, `center`,
`destination`, or the `@lat,lng` path form) and writes the same place for the chosen app. The
bubble still shows the link as it arrived, so what was sent is what is seen; only where it opens
changes. On iOS this rides `OpenURLAction` on the conversation; on Android the `LinkAnnotation`
target is rewritten while the visible text stays. Every non-location link is untouched.

Not in the list: Organic Maps / OsmAnd (deep links differ per platform and they are rare among
the people this app is for), HERE WeGo, Yandex. Easy to add later -- one case in `MapsApp` and
one host in `LocationLink.coordinates`.

## J174 — Read receipts while the thread is open; Copy / Save on a picture; Android Info (iOS 2.058, Android 2.055)

Don, 5 Sep 2026: *"when I long press a photo, the only option is to delete it, but the photo should
probably also allow you to copy it or to save it ... under info, mine are not showing the read
time and date, but they are showing the send and received."*

1. **Read time.** `markRead` ran once, when the thread appeared. A message that arrived while the
   thread was already open -- which is how two people testing across a table always have it --
   was never acknowledged until the next visit, so the sender's Info showed Delivered and no Read.
   Both platforms now call `markRead` whenever the message count changes while the thread is up.
2. **Copy / Save to Photos** on a picture's long press, both platforms, never for view-once. iOS
   uses the pasteboard and `UIImageWriteToSavedPhotosAlbum` (add-only photo permission, new
   `NSPhotoLibraryAddUsageDescription`); Android puts a content URI on the clipboard through the
   existing cache FileProvider and writes a JPEG into Pictures/Channel through MediaStore.
3. **Android had no Info item.** Added, outbound messages only, the same three lines as iOS.

### Noted for later, not built (Don, 5 Sep)

- **Clipboard into the AI.** The linked AI should be able to take the clipboard as input --
  a copied photo or anything else -- and put its result into the composer, not sent, as the
  existing AI compose does for text. Needs the provider's image input and a consent line about
  what leaves the phone; the photo providers differ in what they return (`J161`).
- **Long press on a picture in the composer** (a staged photo) should offer Copy and Save too.

## J175 — The clipboard row (iOS 2.059, Android 2.056), and the two-list loop

Don, 5 Sep 2026: *"duplicate the android keyboard feature that allows you to view the current
contents of the clipboard below the text field, and you can hit the button and it will paste ...
it's actually showing two clipboard fields."*

**Android**: a one-line row above the composer showing the current clip -- the text, or "Photo"
for a copied picture -- and a tap pastes it into the draft or stages the picture like a picked
one. The row is the app's, so every keyboard gets it. The Samsung keyboard's second entry is the
keyboard's own history; Android shows an app only the current clip, and only while in focus, so
there is one row. The paste icon inside the field is gone: one mechanism.

**iOS** cannot do the row. Reading the pasteboard to show it raises the "Allow Paste?" banner on
every read unless the user flips a Settings switch, and there is no history. `UIPasteControl` is
Apple's answer: it lights when the clipboard holds text or a picture, and the tap is the consent,
so nothing is read until asked and no banner appears. Replaces the `J115` §6 button, and adds
what that button lacked: a copied picture pastes as a photo.

**The loop.** `TODO.md` (pending) and `DOCS-TODO.md` (shipped, undocumented) now exist, seeded
from `J155`–`J175`, with the rule in `CLAUDE.md`. Don: *"we're adding a lot of features lately and
it would be easy to forget some of these capabilities and leave them out of the docs."*

## J176 — The per-message mark is a disc on both platforms (iOS 2.060, Android 2.057)

Don, 5 Sep 2026: *"The little tag that tells you the pipeline used for a message on the iPhone is
just the letter, and on the android is a letter inside a little colored circle, so they should
probably match, probably the colored circle is a little bit easier to read."*

Both drew the same thing -- a coloured letter on a small garnet chip. On iOS the chip sat on the
garnet outbound bubble and vanished, leaving a bare letter; on Android the bubble is a different
colour so the chip read as a badge. Now both draw a 14-point disc filled with the channel colour
and the letter in the garnet ground, ExtraBold. The disc brings its own contrast to any bubble, so
`J27`'s one-palette rule still holds. iOS keeps the relay ring outside the disc.

## J177 — Sent means written; no path means wait (iOS 2.061, Android 2.058)

Don, 5 Sep 2026: *"you don't attempt a connection unless you see you have a pipeline to the other
device ... if an escalation pipeline is not available, the message could be stored locally on the
device, of course, encrypted, to be sent at the next opportunity when a pipeline appears."*

Most of that was already the shape (`J21`, `MessageStore.attemptDelivery`): only visible peers are
tried, and with none the message stays queued and sealed. Two gaps closed:

1. **A queued Bluetooth write was reported as sent.** `send` to a discovered-but-unconnected peer
   dropped the envelope into a transient buffer and returned, and the store marked the message sent
   for a write that had not happened -- lost for good if the peer never reconnected before the app
   restarted. Now `send` connects on the spot (an explicit send outranks the discovery cooldown),
   waits up to 15 s for the write to complete, and throws otherwise. The transient buffer is
   cleared at the end of every encounter; the store's queue is the only durable one. A subscribed
   central (peripheral role) is still served over notify at once. Both platforms.
2. **A transport failure is not a failure.** `attemptDelivery` marked the message FAILED when every
   rung threw; now it stays QUEUED, the clock, and FAILED is reserved for what a retry cannot fix
   (sealing, size). Queued messages are retried on their own every 30 s while the app is open, on
   coming to the foreground, on Check Now, and on opening the chat.

Order of the ladder unchanged: Wi-Fi Direct, Bluetooth, LAN, Internet -- most private first
(`J21`). Don listed Bluetooth before Wi-Fi Direct; left as is pending his call.

## J178 — Location as a message type; the receiver picks the map (design, 5 Sep 2026)

Don, 5 Sep 2026, two messages, condensed: *"As long as we're sending to another channel user ...
you could just send the raw latitude longitude along with some kind of token indicating this is a
location message along with a flag that says will the location be updated on some interval with
my expiration time. And if you do that, the receiver of the location is the only one that has to
determine which app is used to display the location ... the message itself doesn't necessarily
have to be visible in the interface in the usual way ... when there is an active live location
being shared in a conversation ... a persistent icon appears maybe somewhere at the top of the
screen that could be touched to display the map ... in addition to displaying an arrow pointing a
direction to somebody else, assuming that the distance is, let's say, within a mile ... a long
press could offer the option on that map display to pass off to the external mapping thing with a
destination for navigation."* And: *"in configuration, we do need to offer a list of the mapping
apps that are found on the device ... so we know which ones we can offer to pass location off to."*

This supersedes `J173`'s **Send As**: between two Channel phones the wire carries coordinates,
not a link, so there is nothing to choose on the sending side. **Open With** stays and becomes
the list of map apps actually installed (plus the in-app map).

### Wire
A location message is a text payload whose body is one token, no prose:
`loc:1;<session>;<lat>;<lng>;<accuracy m>;<interval s>;<expires unix>;<seq>` -- `interval` 0 and
`expires` 0 for a one-shot. Text, not a new payload type, so no Rust change and no break for an
older build (it shows the token). Sealed like any message; may share an envelope later.

### Receiver
- One bubble per session. An update with a known `session` replaces the previous position; the
  thread does not grow by one line per fix ("doesn't have to be visible in the usual way").
- The bubble is a card: Location · Live until 4:10 PM · updated 2 min ago · 0.4 mi NE.
- Tap: the in-app map -- MapKit on iOS, osmdroid on Android (`J120` §2) -- with both phones, the
  bearing arrow and distance when the other is within about a mile, and the live position moving.
- Long press on the map: Open in <each installed map app> for navigation, Copy coordinates.
- **While a session is live in a thread, a pin sits in the brand bar beside the lights**; tap
  opens the map. Don: "prevent you having to scroll a mile to go find the location that was
  shared two hours ago after 100 more messages were typed."

### Sender
- Send my location now (one fix), or Share live for 15 min / 1 h / 8 h (`J120` §1); one tap to
  stop. Updates every 60 s while the app is open, and one fix whenever the app wakes to fetch;
  background continuous tracking is not attempted in this pass.
- Settings › Location › Open With: Apple Maps (iOS), Google Maps, Waze -- only the ones found
  on the phone (`canOpenURL` / package query), plus the in-app map.

### Order of work
1. Token, parsing, one-shot card, session collapsing, Open With from installed apps. 
2. Live sharing with the stop control and the brand-bar pin.
3. In-app map with arrow, distance and hand-off.

## J179 — The Internet is a fallback, not a shadow copy (iOS 2.062, Android 2.059)

Don, 5 Sep 2026: *"I don't know if I like the idea of automatically sharing every Bluetooth
message to the server because part of the point of using Bluetooth was to keep your message off
the Internet ... every message that you can avoid sending to the Internet is a security benefit.
So if Internet is turned on, Internet could be a fallback if the message send fails by local
transfer ... triggered by a timeout of some kind ... 15 or 30 minutes ... and perhaps that timing
should be configurable probably on the contact page."*

This retires `J85` (every radio send also deposited at the relay) and with it `J168`'s "I when the
relay also carried it" rule, which only existed because of J85. `J177` made a radio success mean a
completed write, so the mark no longer needs a hedge.

**The rule, in `attemptDelivery` on both platforms**
1. Radios first, and alone. If any radio rung can see the contact, the message is offered to
   those peers; a completed write ends it, marked B / W / L, and the Internet never sees it.
2. The Internet carries it only when (a) no radio can see the contact -- there is nothing local to
   wait for -- or (b) a radio can see them but the message has waited the contact's **Internet
   fallback** time since it was written. Until then it stays queued and the 30-second retry loop
   asks again.
3. The contact page has the setting: At Once · After 5 / 15 / 30 Minutes · After an Hour · Never.
   Default 15 minutes. Never means radio only for that person.
4. Receipts, policy updates and cards follow the same preference: radio when one can see the
   contact, Internet otherwise (no timer -- they are small and time-sensitive).
5. Photos: Nearby Only (`J*` photosDirectOnly) still keeps pictures off the Internet regardless.

**What this costs.** With a radio in sight that cannot actually complete a write (the peer moved
on), a message for a 15-minute contact takes up to 15 minutes to leave. That is the trade Don
asked for, per contact, and the default can be set to At Once for anyone it does not suit.

## J180 — Clipboard Preview on iPhone, as a switch (iOS 2.063)

Don, 5 Sep 2026: *"So are you saying there's no way to preview the clipboard on an iPhone? Because
I would think at worst, you could just go ahead and paste into a box and immediately recopy it."*

There is a way; the limit is Apple's consent rule. Reading what another app copied raises the
"Allow Paste?" alert on every read, unless the user sets Settings › Channel › Paste from Other Apps
to Allow, after which reads are silent. So: **Settings › Privacy › Clipboard Preview**, off by
default. On, the conversation gets the same row Android has (`J175`): the text or "Photo", one
tap to paste or stage. The row re-reads on app activation and on clipboard change, by change
count, so one read per new clip. Off, the paste control (`J175`) stays. Don's paste-then-recopy
is the same consent moment made by hand; the row skips the extra tap.

## J178 §built — passes 1 and 2 (iOS 2.064, Android 2.060)

Don, 5 Sep 2026, while this was being written: *"the initial message should display the map in
the chat but for a persistent location, the little icon up in the upper right corner also appears
as long as the share is active, and when the share goes dead, that icon could dim and perhaps show
the last known location along with the date time stamp and don't forget, you might be having three
chats and two of them might be sharing location, so you need to keep all that straight."*

Shipped:
- **Wire**: the `loc:1;…` token (above), sent as text. `Send As` is gone from Settings; `Open With`
  lists the map apps found on the phone (`canOpenURL` / package queries; the manifest and
  `LSApplicationQueriesSchemes` name Google Maps and Waze).
- **One bubble per session**, both directions: an update replaces the earlier bubble of its
  session and does not notify -- it moves, it is not news.
- **The card**: Location / Live Location / Live Location Ended, "Until 4:10 PM · Updated 2 minutes
  ago". Tap opens Open With's app; long press: Open in <each installed app>, Copy Coordinates.
- **Live sharing**: `+` › Location › Send My Location, Share Live for 15 Minutes / 1 Hour /
  8 Hours, Stop Sharing. A fix every 60 s while the app is open; ends by itself. Sessions are keyed
  by thread on both platforms, so two of three chats sharing at once stay separate.
- **The pin** beside the lights, per thread: bright while the share runs, dimmed once it has
  ended, and it opens the last known position either way; the card under it carries the time.

Not yet (pass 3, `TODO.md`): the map drawn inside the chat -- MapKit on iOS, osmdroid on
Android -- with the bearing arrow and distance within about a mile. Until then the card opens the
chosen map app, which is the hand-off Don described, one tap earlier than WhatsApp.

## J181 — The sender's countdown (iOS 2.065, Android 2.061)

Don, 5 Sep 2026: *"the person doing the sharing of location needs a second icon so he'll know that
a share is active in case he wants to end it early ... or in case he wants to renew it if it
expires, again using a long press for those options, so the icon for the sender probably should
have a countdown clock."*

Beside the lights, in the thread where you are sharing: a timer glyph and the time left (12m,
1h 05m, 7h), refreshed every half minute. Tap opens the map; long press: Extend 15 Minutes,
Extend 1 Hour, Stop Sharing. Once it has ended the `J178` pin takes over, dimmed, and for your own
ended share its long press offers Share Again for 15 Minutes / 1 Hour / 8 Hours. Received shares
keep the plain pin. Per thread on both platforms.

## J182 — Setup: the interview (design, 5 Sep 2026, awaiting Don's go)

Don, 5 Sep 2026: *"we need to have a better on-boarding process for the new user that gives
step-by-step the things we want every user to have to do ... entering their name for the very
first profile ... this normally would be your real name ... we're gonna be a lot more wordy than
we are anywhere in the settings ... but we will be careful to use the proper terminology ...
notifications, the push versus fetch behavior ... the list of other social apps ... setting up AI
with an API key ... cell phone number ... at least one email ... a potential handle ... a setup
button that will bring you back to this interview ... closed anytime and resumed at any time after
the mandatory items have been filled out ... Once you get the first couple of items in, the other
items that you ask can be skipped, and they'll also appear a Finish Later button."*

Proposed steps: 1 Welcome · 2 Name and picture (mandatory; real name recommended, not enforced;
profiles mentioned) · 3 Fetch new messages (mandatory; notification permission and sound here) ·
4 Reach (four switches, Relay for Others as opt-in; radio permissions asked on switch-on) ·
5 Lock (Face ID / PIN / Distress Code) · 6 Find me by (phone via text or voice, email; handle when
`J124` exists) · 7 Calls (which apps) · 8 AI (own key; keyless means Channel pays, a business
decision) · 9 Location (Open With) · 10 Done.

Left out: pairing (its own flow), backup (prompt when there is something to back up), quiet hours /
translation / read receipts / disappearing (Settings with defaults), extra profiles (pointed to).

Mechanics: progress persisted per phone; opens on first launch until steps 2 and 3 are done; Finish
Later after that; Settings › Setup re-enters at any step with answers kept; all copy in one file
per platform for translation. Build after Don's reply.

## J178 §3 — The map inside the app (iOS 2.066, Android 2.062)

Tapping a location card, the pin, or the sender's countdown opens the map in the app: MapKit on
iOS, osmdroid on OpenStreetMap tiles on Android (`J120` §2 -- tiles are fetched only while the map
is open, never in the background, never for a message nobody looked at). Both phones are on it; a
live share moves as updates arrive because the sheet reads the newest message of its session from
the store. Under the map: the distance ("0.4 mi NE"), and an arrow that points at the other phone
relative to the way this one is facing (heading from the compass on iOS, the rotation vector on
Android), garnet within about a mile, grey beyond. The toolbar menu hands off to any installed map
app for navigation. The long press on a card still hands off directly, and Open With still decides
which app a hand-off uses by default.

## J183 — One way to be found per profile; verified is not advertised; what a handle needs

Don, 5 Sep 2026: *"for every profile you create, there has to be at least one unique means of being
found, because if you put all three numbers on a single profile ... you can't use those on other
profiles as a means of being found, which is why we need to be supporting the handle method as soon
as feasible ... apparently you have to link the handle with a phone number that is verified, and
just because a phone number is verified doesn't mean it has to be advertised first, so make sure
that logic is in the code and explained."*

**What the code already does.** `J94`: a value with Find me by on in one profile is refused in
every other profile on the phone (`claimedElsewhere` in `ContactEditView.swift` /
`ContactEditScreen.kt`). Verification (`verifiedProofs`, held against the value, `J68a`) and
Find me by (`ContactField.findMeBy`, the opt-in to be reachable) are separate switches: a verified
value with Find me by off is proven and private. Both stay as they are.

**What was missing.** The rule "give each profile at least one way of its own" was nowhere. It is
now in Help › Being found (three new sections), and it goes into Setup step 6 (`J182`). It is
guidance, not enforcement: a profile with no way to be found is legitimate -- it can still pair
in person and message everyone it has paired with -- so the app says so rather than refusing.

**Handles, the shape.** One per profile. Registration is blind (`J124`) so the server never holds
a readable list. The anti-abuse anchor is a verified phone proof presented at registration --
the server learns that *a* proven number backs the handle, not which one -- and that number is
not thereby advertised; Find me by on it stays the user's separate choice. Moved to the top of
`TODO.md`'s "Next to build".

## J184 — "Get a key at …" (iOS 2.067, Android 2.063)

Don, 5 Sep 2026: *"when you get to the API key entry, you need to have clickable links to the place
where you could sign up for an API account, for each of the providers we support."*

Settings › AI, under the key field: one link, for the provider chosen above it -- OpenAI
(platform.openai.com/api-keys), Anthropic (console.anthropic.com/settings/keys), xAI
(console.x.ai). Opens in the browser; the provider's page handles sign-up and the key. The same
link goes into Setup step 8 (`J182`). Ships with the `J183` help text.

### J183 addendum — unique across everyone

Don, 5 Sep 2026: *"the handle has to be unique across the entire user base."* Confirmed as the
requirement `J124` was written for: the server is the one authority on handle uniqueness, world-wide,
and it enforces it blind -- a registration presents a hash-derived token the server can compare
and refuse without ever holding the readable handle. Uniqueness per phone or per profile would not
be enough; two people in different cities must not both be `@lusmar123`. Case-insensitive, one
canonical form (lower-case, no leading `@`).

## J185 — What the AI is for (iOS 2.068, Android 2.064)

Don, 5 Sep 2026: *"if those AI settings are there and they are, you need to go ahead and build some
of the AI feature, at least enough to make it worth the trouble ... Look at what other apps, such
as WhatsApp, offers for AI use with Meta, and there may be other examples."*

What the big three ship in a messenger, September 2026: **WhatsApp / Meta AI** -- Writing Help
(rephrase, proofread, tone: friendlier, professional, shorter), reply suggestions from context,
unread-message summaries, image generation, and a chat with the assistant. **Apple Messages** --
Writing Tools (proofread, rewrite in three styles, summarize), Smart Reply, per-thread summaries,
Genmoji, suggested polls. **Google Messages** -- Magic Compose (rewrite in seven styles, on-device
Gemini Nano with the last 20 messages), Gemini in chat, image edits. **Telegram** -- summaries of
channel posts. The common core is three things: help writing, replies from context, and catching
up. That is what shipped, on the user's own key, each use asked first (`J118`):

- **The sparkle menu** in the composer: Compose…, Fix Spelling & Grammar, Make It Shorter, Make It
  Friendlier, Make It More Formal (the draft only goes out), Suggest Replies (the last ten
  messages go out; three one-line replies come back, tap one).
- **Summarize with AI** in the thread menu: the last thirty messages, bullet points, shown and
  never stored.
- **Long press a message: Translate with AI** into the phone's language, with the source language
  named. Long press a picture: **Ask AI About Photo** -- the picture and a question go out; the
  answer can be used as a reply. Never for view-once. This is the first half of the `J174` note
  (the clipboard as AI input); a staged picture in the composer is the other half.
- One sheet for all of it: what will be sent, a Send button that is the consent, the result, Use.

Not built: image generation and Genmoji (`J117` §3 -- cost and a very different consent story),
suggested polls (no polls), a free-form assistant chat outside a thread.

## J186 — A hosted AI at double cost (design, awaiting the business decision)

Don, 5 Sep 2026: *"we should probably offer to charge people to use our own APIs for AI, but we're
gonna charge whatever double our actual cost is ... for somebody who doesn't use API very often,
that might be the better deal ... someone who uses it a lot will be better off with their own
account, and we will of course be transparent with that."*

Shape: a Netlify function that carries the same requests on Channel's provider key, metered per
profile token; credits sold in the apps. Constraint that shapes it: Apple and Google require
in-app digital goods to go through their billing (30% / 15%), so credits are consumable in-app
purchases and the "double cost" price is set after the store cut, and said so. The Settings › AI
page then offers two rows -- Your own key (free, your account) and Channel credits (price per
message shown) -- with the sentence Don asked for: heavy users pay less with their own key. Not
started; needs the App Store / Play product setup and a decision on the credit denominations.

## J187 — The staged picture gets the same long press (iOS 2.069, Android 2.065)

The other half of the `J174` note. A picture on the preview sheet, before it is sent, has the menu a
received one has: Copy, Save to Photos, and Ask AI About Photo (`J185`) -- and the answer lands in
the caption field, not sent, which is what Don asked for: *"the result of the AI exchange would show
up in the message box ready to be sent but not necessarily sent."* The clipboard side is done by
the paste control and clipboard row (`J175`, `J180`): a copied picture pastes onto this sheet, and
from here it can go to the AI.

## J188 — The capture banner is gone (iOS 2.070)

Don, 5 Sep 2026, with a photo of the AI key page: *"That useless red banner is covering up the input
field where she has to put the API key. So get rid of the banner, you don't have to announce that
you just do it and you mention it in the apps documentation."* And: *"Try not to build things
without looking at them on the simulator afterwards to catch obvious problems like that."*

`J145`'s explanatory card sat behind the shielded content so a screenshot would show it instead of
the content. Inside a Form the shielded Section did not fill its row, so the card drew live, over
the fields. Now: the shield hides content from a capture and nothing else -- a capture of a
shielded screen is blank. The three Settings pages that shielded a whole Section (PIN, backup
passphrase, AI key) are no longer shielded at all: their secret fields are masked, so a screenshot
of them shows dots, and the sheet is usable again. The view-once viewer and the lock screen pad
keep the shield. Help › Privacy says so. Android (`FLAG_SECURE`) never had a banner.

On the second point: the simulator panel had crashed earlier in the day and computer-use access to
it was declined, so the last several builds shipped unseen. Standing rule from here: after a UI
change, build for the simulator and look at the screen before the archive.

## J189 — Several AI accounts, and an AI you can chat with as a contact (iOS 2.071, Android 2.067)

Don, 5 Sep 2026: *"Allow a user to enter more than one ai api and then choose which is in charge of
ai features in the app but for each ai key entered, add a checkbox to enable adding a contact to
every current and future profile for that ai that can be chatted with the same as any other
contact. That profile would only be able to chat with the contact marked as 'me' ... the master
prompt for such a feature would have to be carefully constructed and what info the ai would have
available to it would have to be curated or sandboxed in some way. You'd also have to track and
limit usage if they use our account."*

- **Accounts.** Settings › AI lists accounts (provider, model, key); Add Account; one is chosen
  under Use for AI Features when there is more than one. The single key of `J161` migrates into
  the first account. Every account counts requests and characters, so a hosted account (`J186`)
  can be capped; own-key accounts are not capped.
- **Chat as a Contact.** A switch per account. On, a contact named ChatGPT, Claude or Grok appears
  in every profile -- the same id everywhere (a v3 UUID of the account id, identical on both
  platforms) -- and in every profile made later, because a session's store syncs the set when it
  is created and whenever Settings › AI changes. Off, or the account removed, and the contact goes.
- **The thread.** A message to an AI contact is filed as delivered -- nothing carries it -- and
  the store calls the provider with the last forty messages of *that thread* as proper user /
  assistant turns. The reply is filed as an inbound message, no notification. No lights, no
  call buttons, no verify, no Internet fallback on the contact page; an AI badge instead of a
  pairing badge. Only the phone's own profile talks to it; it cannot reach anyone else.
- **The master prompt** names the provider and the user, and says: you know only this
  conversation, you have no memory of other sessions and no access to the user's other chats,
  contacts, location or phone, you must not pretend otherwise, you cannot send messages or take
  actions, be concise, answer in the user's language, never ask for passwords, keys or codes.
  Nothing else on the phone is ever sent.
- **Verified on the simulator**, signed build: account saved, contact appeared, message sent, the
  provider answered (with its own rejection of a throwaway key, which proves the round trip).

Found along the way: the keychain does not work in an *unsigned* simulator build, which is why
the simulator's store looked broken earlier today, and why Don's "the API key isn't saved and
says Off" needed a second look -- on the phone the save works, but the Settings row read the
keychain once and showed Off until a relaunch. The row now re-reads on every appearance. The AI
compose sheet's include-chat switch is tinted garnet on both platforms (Don: low contrast).

## J190 — Handles before the interview (order, and two rules to decide)

Don, 5 Sep 2026: *"Add handle feature before interview since the handle would be optionally part
of that and decide how one changes his handle and what happens to messages routed to the
deprecated one -- each profile of course can optionally have a handle too."*

Order set: handles (`J124`/`J183`), then the setup interview (`J182`). Rules to build in:
- **Changing a handle** releases the old one after a grace period (proposed: 30 days) during
  which it is reserved and cannot be claimed by anyone else, so a typo or a change of heart does
  not hand the name to a stranger the same day.
- **Messages to the old handle** during the grace period still reach the profile -- the slot is
  the profile's until release. After release the slot answers nobody, exactly like a number
  nobody proved: an invitation left there sits unanswered (`J104`). Nothing forwards, because
  forwarding would let an old handle keep finding you after you gave it up.
- One optional handle per profile; unique across everyone (`J183` addendum).

## J191 — Bridging other channels: feasibility (5 Sep 2026)

Don, 5 Sep 2026: *"what's possible as far as supporting other channels ie contacts you contact via
other apps -- ie Mary@imessage or joe@facebook or sally@instagram or Ted@whatsapp? ... Provided
the platform can be fed and read as a proper client in some way even if clearly not the same
level of security when you only control one end."* And: *"This is more of a feasibility
exploration at this point."*

What each platform allows a third-party client to do with a *personal* account, today:
- **iMessage**: nothing. No API; the only working bridges run a logged-in Mac as a robot.
- **WhatsApp**: only the Business API, for business accounts talking to customers. A personal
  account driven by anything but WhatsApp's own app breaks its terms and gets banned.
- **Facebook Messenger / Instagram**: only the Pages / business messaging API. No personal client.
- **Signal**: no official client API; unofficial ones exist and Signal has asked them to stop.
- **Telegram**: yes -- a full, official client API (TDLib). A proper client with your own account.
- **SMS / MMS**: on Android an app can be the default SMS app and send and receive fully; on iOS
  no app can read or send SMS.
- **Matrix bridges** (mautrix and kin) cover WhatsApp, iMessage, Signal, Instagram by running a
  server logged in as the user; fragile, against some platforms' terms, and the plaintext sits on
  that server. Viable only as an advanced, user-hosted option.

So: Telegram and Android SMS are the two honest candidates for `ted@telegram` and `ted@sms`
contacts, with the thread marked as *not end-to-end through Channel*. The rest stay out unless
the user runs a bridge of their own. Not started.

## J192 — Own AI endpoint over Tailscale (note, 5 Sep 2026)

Don, 5 Sep 2026: *"An advanced feature might even allow a user to connect to his own openclaw for
the ai features ... if there is a nice way to penetrate nat for that even better (tailscale)."*
And: *"some kind of tailscale support in the app itself might be useful for some people but maybe
if on the phone it's supported anyway."*

Shape: a fourth provider, **Custom**, with a base URL and optional key, speaking the OpenAI chat
format most self-hosted servers accept. If the phone runs the Tailscale app, a tailnet address
in that URL simply works -- Tailscale on iOS and Android is a VPN the whole phone uses, so the
app needs nothing of its own. Building Tailscale *into* the app is possible (their library) but
heavy and duplicates what the phone already has; not planned unless testers ask. Custom provider:
small, and next in the AI queue after `J186`.

## J193 — The first message is the invitation (iOS 2.072, Android 2.068)

Don, 5 Sep 2026: *"there is too much resistance to sending a new message to a new contact. If the
contact you want to send a message to has verified his control of whatever it is he presents as
his incoming tag ... then you should be able to send a message. And not just send a request to
send a message. When a user receives a message from someone the first time, they get to see the
basic contact information of the sender and an option to allow messages from this contact ... the
choices are to accept the message, which immediately creates a contact ... and does whatever code
exchange is necessary ... usually that will be an unverified contact."*

**Sender.** Write to anyone whose card has a number or address Channel can reach, paired or not.
The message files as queued (the clock) and the app leaves the invitation at that value on its
own -- no separate request step. One line above the composer: *Sends when Mary accepts*, then
*Waiting for Mary to accept*. The moment Mary accepts, the key exchange completes and the queued
message goes out sealed, by the retry loop (`J177`), with no further tap. The old banner ("nothing
can be sent -- there is no key") now appears only for a card with nothing to reach.

**Receiver.** Unchanged in shape: the request shows the sender's name and the value they used;
Accept creates the contact (unverified, `J68`), runs the exchange, and the first message arrives
seconds later, sealed. Verification stays on the contact page for later.

**Not done, on purpose: showing the first message's text before Accept.** The invitation is
readable by the server (it already carries the sender's name and key; there is no recipient key to
seal to before the exchange). Putting the message text in it would put message content on the
server in the clear, which the product has never done. The trade is Don's to make; the flow above
gets the speed without it -- accept, and the text is there before the screen settles.

## J194 — A Grok bot as a contact (note, 5 Sep 2026)

Don, 5 Sep 2026: *"the possibility of connecting a grok bot as a contact ... the bot function has
its own browser and a user can authenticate on the website ... the bot structure might be
something useful to give another way of using AI within our app in a way that could perhaps have
some context ongoing."* Depends on what xAI's bot surface can drive; if it can hold a session in
a browser and post through a web client, a Channel web client (`J147` linked devices) is the
prerequisite. Parked until Don has checked with Grok.

## J195 — Documentation on the public site, and the export declaration settled (5 Sep 2026)

**1. The docs are published.** Don: *"Is our full documentation available on our public facing
website yet? If not, please add it ... that will make it easier for me to talk to other AIs about
possibilities of integrating."* `scripts/build-site-docs.py` generates `web/docs/` from the repo:
thirteen documents plus the eleven shipped help pages, each as HTML and as raw markdown, an index,
`all.md` (the whole set in one file, ~875 KB, one fetch for an assistant) and `llms.txt`. Published
on the PUBLIC site (`web/`, channelmessenger.net), not the relay -- the relay sets
`X-Robots-Tag: noindex` and `Cache-Control: no-store` on every path, which is right for a mailbox
and wrong for documentation. Added to `sitemap.xml`. Re-run the script whenever the docs change.

The privacy policy and the encryption note are deliberately NOT generated into `docs/`: they are
published by hand as `/privacy.html` and `/encryption.html`. Generating a second copy from the
stale `store/privacy-policy.md` put a contradictory policy at a public URL, found and removed the
same day. `docs/index.html` links the live pages instead.

**2. Three domains, one site.** channelmessenger.net serves it; channel-messenger.net and
channel-messenger.com (and every www form) 301 to it, already configured in `web/netlify.toml` and
verified over the network on 5 Sep 2026. Nothing to change.

**3. The export declaration: exempt, and the three copies now agree.** The shipped app has always
set `ITSAppUsesNonExemptEncryption: false`; `EXPORT-COMPLIANCE.md`'s banner claimed the code said
`true` and called `false` a false declaration; `web/encryption.html` §6 publicly said `true`. Don
settled it: *"we already discussed the non-exempt thing ... the controversy was whether our
protocols were somehow not standard but our protocols don't speak to the method of encryption so we
want our best good faith estimate as to our compliance posture without creating undue burden by
over broad interpretation of what is exempt vs non exempt."*

The reasoning, now recorded in all three places: the app defines no cryptography of its own
(X25519, Ed25519, ChaCha20-Poly1305, SHA-256, AES-GCM are all published standards) and PROTOCOL.md
specifies routing, addressing and retention rather than a cipher. Apple's own validation agrees --
it refuses to create an encryption declaration for an app without proprietary cryptography.
Implementing a published algorithm in Rust instead of calling the OS copy does not make the
algorithm non-standard, which was the over-broad reading. Reopen only if a primitive of our own
ever ships. `app/project.yml`, `EXPORT-COMPLIANCE.md` and `web/encryption.html` §6 must change
together.

**4. The site rewrite.** `web/i18n/template.html` and `en.json` rewritten against the shipped code,
with a status board in four labelled states -- Shipped and tested, Shipped not fully vetted, In
progress, Planned (italic) -- a legend, a status date and an editor's note naming `TODO.md`,
`DOCS-TODO.md` and `DECISIONS.md` as the sources of truth. Corrections to `web/privacy.html`: the
page claimed no access to contacts, location or photos, that push was unavailable, and that the
identity key never leaves the device including in a backup -- all false since the features shipped.
`web/encryption.html` §4 claimed pairing performs no key agreement, untrue since `J101`/`J146`.

Found and NOT changed, for Don: `store/privacy-policy.md` and both store listings are stale
against the site; `FRANCE-CRYPTO.md` §4.1's unreconciled France availability question stands;
only `en` exists in `web/i18n/`, so the language switcher offers five pages that were never built.

## J198 — A pasted pairing code never worked (iOS 2.073)

Don, 5 Sep 2026, with a photo of Lusmar's phone: *"She got the email and pasted the code that I
sent her and got this message"* -- *"That pairing code is missing part of itself. Ask them to show
it again."* Nothing was missing.

The scan path decodes the bytes into a `PairingPayload` and sets both `scannedPayload` and
`scannedPeerKey`. The typed path set only `scannedPeerKey`, and `runExchange` refuses without the
payload -- so **every** code that arrived by message or email failed, with a message that blamed
the code. The typed path now decodes, and reports "out of date" or "not a Channel code" honestly
when the decode really fails. Android's `EnterCodeTab` was never affected.

## J199 — Accept, Decline, Block, and the block outlives the request (iOS 2.073, Android 2.069)

Don, 5 Sep 2026: *"stranger contact should be frictionless with option to accept or decline or
block if we choose (for spam or uninvited porn etc) and of course we store the blocked users in
case we want to unblock one day but it's per device."*

`J193` made the sending side frictionless; this is the receiving side. A request now offers
**Accept**, **Decline** (drops this one) and **Block**. Block records the sender's identity key,
per profile, in the store, and every later invitation from that key is dropped on arrival --
silently, because telling a blocked sender they were blocked only tells them to try another
number. Settings › Privacy › **Blocked** lists them by the first characters of the key (a blocked
stranger was never accepted, so no name was ever exchanged) with Unblock. Unblocking does not
re-deliver what was refused.

**"Per device", and the linked-device question it raises.** Don: *"when you have a desktop app you
invite to be on your account you have to figure out how they get all the messages and they
probably have to come from the device that auths the other device in a device to device dump."*
Recorded for `J147`: the authorising phone is the source of truth and hands the new device the
history, the contacts and the blocklist directly; the server is not asked to hold or replay
anything, because it cannot read any of it and holding it would create the record the product
exists to avoid.

## J200 — Where you put a code, and what verification is called (iOS 2.073, Android 2.069)

Don, 5 Sep 2026: *"where to post the code you have to open verify in person and that should be
renamed verify of course or verify contact perhaps and there are of course different levels of
verification but think about how to name and frame this feature."*

- **A top-level way in.** Contacts › **+** is now a menu: **New Contact** and **I Have a Code**.
  Before this, the only route to Enter Code was a button on an existing contact, so someone sent a
  code by email had nowhere to put it. The empty state says so too.
- **The button is renamed.** "Verify in person" becomes **Connect or Verify** on an unpaired card
  and **Verify Again** on a paired one. In person is one of the three routes inside (My Code, Scan,
  Enter Code), not the name of the feature.
- **The levels already existed and are kept** (`IdentityConfidence`): **Verified** -- keys
  exchanged and the two people compared the confirmation numbers; **Not verified** -- a real key
  agreement, nobody compared numbers; **Weak** -- paired by a method with no real secret; and no
  badge at all when there are no keys. The badge says the word, so it reads without colour.
- The invitation text no longer offers a download the app cannot honour yet: it names the exact
  three taps and says to reply for an invitation if they do not have Channel.

## J201 — The fallback timer should not punish a radio that fails (iOS 2.073, Android 2.069)

Don, 5 Sep 2026, on the first real message between the two phones: *"hers is stuck on the clock
icon and not going to the server"* ... *"never mind it eventually got to me."* Fifteen minutes
later, because `J179`'s timer treats a visible radio peer as a reason to wait, and every write to
that peer had failed.

The timer means "prefer the radio while the radio can work", not "hold the message because a radio
is in sight". Now: radios are tried first, as before; if one takes it, the Internet never sees it;
if **every** radio attempt fails on this pass, the Internet carries it at once rather than at the
deadline. `Never` still means never. The privacy intent is unchanged -- the radio is still always
tried first and still wins when it works.

## J202 — The per-message mark is legible now, and a wallpaper must not undo it (iOS 2.074, Android 2.070)

Don, 5 Sep 2026, on the first messages that crossed by radio in both directions: *"i received it
by W and sent my message to her by B"* -- the first confirmation that Wi-Fi and Bluetooth both
carry between the two phones -- *"and the letters by the messages are too small to read especially
on her iphone which is not a pro max size."*

`J176` made the mark a coloured disc with the letter inside, but at 14pt with a 9pt letter it was
too small on a standard phone. Now a 19pt disc with a 12pt black letter on both platforms, and the
relay ring grows with it. The letter is still the signal and the colour still reinforces it, so it
reads in greyscale (`J27`).

**Wallpaper, planned, with the constraint stated up front.** Don: *"add wallpaper feature of course
as a contact preference ... but have to make sure a wallpaper doesn't obscure text due to bad
contrast."* Per-contact wallpaper, chosen from the picker and stored beside the attachments. What
makes it safe rather than pretty: bubbles keep their own opaque grounds, and the app measures the
chosen image and lays a scrim behind the message area sized to the measured luminance, so text and
marks keep their contrast whatever the picture is. A wallpaper that would still fail is dimmed
rather than refused. Not started; on `TODO.md`.

## J204 — One proof, up to ten handles; what the receiver decides (policy, 5 Sep 2026)

Don, 5 Sep 2026: *"we allow up to 10 profiles and a user could have a number he verifies and then
create up to 10 profiles per device all publishing only the handle and that's ok. we don't have to
prevent that. what he does with it is between him and god ... if he messages a stranger using their
public inbox which he has to know or find in some way maybe because the user was selling something
and posted it on facebook then the receiver decides on first message whether to accept it and can
block it at any time."*

This settles the handle policy and corrects the sketch in `J183`/`J190`.

**One verified value may back a handle on every profile on the phone, up to the ten-profile cap
(`ProfilesStore.maxProfiles`).** The number is the *proof*, held privately; the handle is what is
*published*. Ten profiles, ten distinct handles, one proof behind them, and the number itself need
never be advertised -- `Find me by` on it stays a separate switch (`J183`).

**What does NOT change: `J94`.** Two profiles may still not both publish the same number or
address, because a message arriving at that slot could not be routed to one of them. That is a
routing constraint, not a policy one. Handles do not hit it: each handle is unique across everyone
(`J183` addendum), so the slot is unambiguous.

**Where the defence actually sits.** Not in gatekeeping who may hold a handle -- Don is right that
this is not ours to police, and a person with ten personas is a legitimate user of a product built
for exactly that. The defence is at the receiving end, and it is already built: an unknown sender
gets a message request, the receiver reads who it is and decides, and Block is permanent and
per-profile (`J199`). Reaching a stranger still requires knowing a value they chose to publish.

**The bounded consequence, recorded honestly.** One burner number buys ten handles rather than one.
That raises the ceiling on bulk-registration abuse by a factor of ten and no further, and the
mitigations from the 2FA-services question stand unchanged: refuse the free public inbox domains
and numbers for `Find me by`, re-prove a published value periodically so a recycled number expires
rather than being trusted forever, and never let proof read as trust in the interface. Blocking is
the user's own remedy and needs no policy from us.


---

# Help pages (as shipped in the apps)


---

<!-- docs/help/find-me-by.md -->

# Being found

You can let people reach you at a phone number or an email address. To do that you must first prove the value is yours, because a number nobody proved could be used to collect requests meant for whoever really holds it.

## Verifying

Add the value to your card, then tap **Verify**. A six-digit code is sent to it:

- **Text message** — the default where it works. Your phone usually fills it in for you.
- **Phone call** — the code is read aloud, twice. For a landline, a number that cannot receive texts, or a number that only lives in another app.
- **Email** — for an address.

The code expires in ten minutes. It can be received on *any* device that has that number — it does not have to be the phone running Channel — and typed in here.

## What *Verified* means

A tick means this phone proved control of the value, by the route shown (*Verified by text*, *by call*, *by email*). A call and a text are different proofs; the app records which, and never pretends one is the other.

A value you have not verified is inert: it is not published, and nobody can reach you at it. This is deliberate.

## What is stored, and where

Our server keeps **no list** of who is verified, and no list of numbers. A request left at a number is addressed to a slot derived from the number itself; only a phone that has proved the number can collect from that slot. The number is used to compute the address and is never stored beside your identity.

## Two profiles, one number

Two profiles on the same phone may not both present the same number or address. The app refuses the second tick, because two identities answering at one address would reveal that they share a phone.

## One way to be found, per profile

A profile nobody can find can still message anyone it has paired with, but nobody can start a conversation with it. Give each profile you want to be reachable at least one value of its own with **Find me by** on. If you put every number and address you have on one profile, there is nothing left for the others, which is why a handle is coming as a third way.

## Verified is not the same as advertised

A tick says this phone proved the value is yours. **Find me by** says people may reach you at it. They are separate switches on purpose: a verified number can stay private, and it can still vouch for you elsewhere. A handle, when it arrives, will need a verified number behind it as proof that a real person holds it, and that number does not have to be advertised.

## Handles

A handle is a name you choose, unique across Channel, that people can reach you at without knowing a number or an address. It is not built yet. When it is: one handle per profile, registered blind so the server never holds a readable list, backed by a verified number that stays private unless you also turn Find me by on for it.


---

<!-- docs/help/getting-started.md -->

# Getting started

## Profiles

A profile is a separate identity: its own keys, its own contacts, its own conversations. Two profiles on one phone share nothing unless you choose to see their conversations in one list (Settings › *Merge profiles*). Nobody you talk to can tell that two profiles share a phone.

Create one from Settings › Profiles. A new profile can start as a copy of an existing one — *settings only*. Keys, contacts, messages and verification are never copied, because a profile that shared them would not be a separate identity.

## Your card

Your card is the name and picture contacts see, and it is sent to them directly, sealed, when you pair. It is never uploaded anywhere. Change it under Contacts › *This is me*.

## Your first contact

There are two ways to reach somebody:

1. **In person** — scan each other's QR code, or read out the confirmation code. See [Pairing](pairing.html).
2. **By a number or address they have verified** — leave a request at their number or email; they see it the next time their phone checks, and accept it with a name of their choosing. Until they accept, the request waits; nothing tells you whether the number is even in use. See [Being found](find-me-by.html).

Channel never reads your phone's address book and never uploads contacts. Every contact in Channel is one you typed or paired with.


---

<!-- docs/help/index.md -->

# Channel Help

This is the complete guide to Channel, and it lives inside the app. It works with no network, and nothing you read here is reported to anyone.

English is the reference text. Any translation is a convenience; where the two differ, the English governs.

## Contents

- [Getting started](getting-started.html) — profiles, your card, and your first contact
- [Pairing](pairing.html) — QR codes, safety numbers and confirmation codes, and what each proves
- [Being found](find-me-by.html) — verifying a number or an address, and what *Verified* means
- [Reach](reach.html) — the four ways a message can travel, and what the lights mean
- [The relay](relay.html) — what our server does, what it learns, and what it never sees
- [Waking your phone](push.html) — how new mail reaches a phone that is asleep, and how to stop that
- [Messages](messages.html) — disappearing, view once, photos, read receipts, translation
- [Privacy on this device](privacy.html) — screenshots, the app lock, the app switcher, contacts
- [Settings](settings.html) — this phone versus this profile
- [When something does not work](troubleshooting.html)


---

<!-- docs/help/messages.md -->

# Messages

## Sealed, always

Every message is sealed on your phone to the contact's key and opened on theirs. Nothing in between — not our relay, not the Wi-Fi, not a phone carrying for you — can read it.

## Which way it went

The small letter on a message says which path carried it: **W**, **B**, **L** or **I**. See [Reach](reach.html).

## Disappearing messages

Set a timer on a conversation and every message after that is removed from *both* phones once it has been seen and the time has run. Both sides see the timer.

## View once

A photo sent *view once* is shown one time and then removed. The recipient's phone shields it from screenshots while it is on screen.

## The + button

Beside the composer: **Photos** (the phone's own picker, out of process), **Camera** (one picture, nothing kept), and **Location**.

## Send my location

Tap **Location** under `+` for the choices: **Send my location** (one fix, taken when you tap, never kept) or **Share live** for 15 minutes, an hour or 8 hours. A live share sends your position every minute while Channel is open and ends by itself; **Stop sharing** ends it sooner. Channel never reads your location otherwise.

A location arrives as a card, one per share, that moves in place as updates come in. Tap it to open the map in the app you chose under Settings › Location › **Open With** (the map apps installed on your phone). Long press for every installed map app, or to copy the coordinates. While someone is sharing live with you, a pin sits beside their name at the top of the thread; tap it to open the map without scrolling back.

## Compose with AI

If you have set up an AI account under Settings › AI, ✦ opens a sheet: your request, and a switch to include the last few messages of the chat. When that switch is on, the sheet shows exactly the lines that will leave — the other person's words too — before you press Compose. The reply is a suggestion; it goes into your draft only when you choose it.

## Photos

Photos are sent as sealed attachments. If *Send photos directly only* is on, they never go through the relay: they wait until the two phones are near each other.

## Read receipts

Both ways or neither. Switching them off stops you sending them and stops you seeing them.

## Messages in other languages

Translation is done on this phone, by the phone's own translator. The text never leaves it to be translated. Turn it off, or choose which languages, under Settings › Notifications.

## Reporting

You can report a conversation. What is sent is the messages *you* choose to include, sealed to us, and nothing else.


---

<!-- docs/help/pairing.md -->

# Pairing

Pairing is how two phones learn each other's keys. Everything after that — every message, every photo — is sealed to those keys, and only those two phones can open it.

## Scanning a QR code

One phone shows, the other scans. The code carries a public key and a short-lived nonce; it does not carry anything secret, so it is safe to show in public. Each phone then sends the other its card, sealed.

## The confirmation code

When two phones pair without a camera — by Bluetooth or on the same Wi-Fi — each shows a six-digit **confirmation code**. Both phones compute it from the secret they just agreed and from both identities, in thirty-second windows. If the codes match, nobody sat in the middle. If they do not, stop: something is between you.

## The safety number

Every conversation has a **safety number**, shown on the contact's page. It is computed from both keys, in the same order on both phones, so it is identical on both ends. Compare it in person once and you have checked, for the life of the pairing, that nobody substituted a key. It changes only if a key changes — a reinstall, a new phone — and Channel tells you when it does.

## What a match proves, and what it does not

- Matching codes prove the two phones in front of you hold the keys they say they hold.
- They do not prove who is holding the phone. That is what the name you give the contact, and your own judgement, are for.

## Requests you did not make

If someone reaches you at a verified number or address, you see a request that says who they *claim* to be. Give them whatever name you like, or none — the name is for you. Accepting starts the pairing; it completes when both phones are next reachable, and you can leave the screen in the meantime.


---

<!-- docs/help/privacy.md -->

# Privacy on this device

## Your contacts

Channel never reads the phone's address book, and never uploads contacts. A contact exists in Channel because you typed it or paired with it. When you pick a number from the phone's contacts, the picker runs outside the app and hands back one value.

## Screenshots

Nothing stops the other person from screenshotting, recording, or photographing what you send them, and Channel would not know. On this phone, two things are shielded from capture, always, with no switch: a **view-once** picture while it is on screen, and the lock screen's PIN pad. A capture of either is blank. Fields where you type a secret, such as a PIN, a backup passphrase or an AI key, show dots, so a screenshot of them shows dots. Everything else you can screenshot like any other app; it is your phone.

## The app switcher

When you leave the app, its card in the app switcher shows a blank garnet page, not your conversations.

## The app lock

Settings › Screen and lock can require the phone's unlock — face, fingerprint or passcode — to open Channel, and again after it has been in the background for a while.

## The app PIN

Settings › Screen & lock › *Set an app PIN* adds a code of your own, 4 to 8 digits. It works even on a phone with no lock at all, and it is asked before anything else — a face alone never opens a PIN-protected Channel. With *Require unlock* also on, it is PIN first, then face or fingerprint. The PIN is never stored; a slow hash of it is. Three wrong tries start a delay that doubles.

## The distress code

A second code, set under the same heading. Entered instead of your PIN, the lock screen shows one small **Confirm**. Confirm destroys everything Channel holds on this phone — the key that seals the stores first, then identities, then every file — and the app opens as if freshly installed. Nothing on screen says it happened. Copies elsewhere (a backup, another device) are untouched.

## What is on the disk

Every store Channel writes is sealed with a key that never leaves this phone's secure hardware. A copied file, a disk image or a stolen backup yields nothing without it.

## What leaves this phone

- Sealed messages and attachments, to the paths you allow.
- Your card, sealed, to contacts you pair with.
- A verification code request, to the number or address you asked to verify.
- A content-free wake registration, if *Wake me when mail arrives* is on. See [Waking your phone](push.html).
- One request to the AI provider you set up, only when you press Compose, carrying exactly what the sheet showed you. See [Messages](messages.html).
- One location fix, only when you tap *Send my location*, as a link in a message.

Nothing else. No analytics, no crash reports, no usage beacons, and Help never contacts anyone.


---

<!-- docs/help/push.md -->

# Waking your phone

A phone that is asleep cannot check its mailbox. There are two ways to deal with that, and you choose.

## Polling

Your phone wakes on the schedule under Settings › Cadence and checks the relay. Nothing about your phone is registered anywhere. The cost is battery and delay: mail waits until the next check.

## *Wake me when mail arrives*

When this is on, the relay can ask Apple or Google to wake your phone the moment an envelope lands for it. The phone then checks its mailbox exactly as it would have anyway, and shows you the message itself.

What Apple or Google receive is a **content-free** wake: no sender, no text, no profile, not even that it is a message rather than a housekeeping wake. They know the phone runs Channel, and when it was woken. What the relay holds is one opaque id per profile against the phone's push token; the id tells it nothing about you.

- Chosen under Settings › Fetch new messages: **Push**, **Every 15 minutes** (the default), or **Manually**.
- **Per profile**, you can choose *check only when I open*. That profile never registers an id, so nothing about it reaches Apple or Google at all. A quieter profile cannot be made louder by the phone-wide switch.
- Turning it off deletes the ids at the relay immediately.

Contacts learn the id from your card when you pair. If you switch a profile off later, the next card they receive carries none, and they simply stop being able to wake you.


---

<!-- docs/help/reach.md -->

# Reach

A message can travel four ways, and Channel takes the most direct one that works right now. Each has a letter, and the letter on a message tells you which one carried it.

| Mark | Path | What it needs |
|---|---|---|
| **W** | Direct Wi-Fi | Both phones nearby, no network at all |
| **B** | Bluetooth | Both phones nearby |
| **L** | Local network | Both phones on the same Wi-Fi |
| **I** | Internet mailbox | The relay, from anywhere |

The first three never leave the room. Only the fourth touches our server, and you can switch it off per profile.

## The lights

Beside each contact, the lights show which paths are open right now. A path that is switched off in Settings is not shown at all.

## *Waiting*

A contact shows **Waiting** when nothing can carry a message to them yet: they are not nearby and there is no mailbox for them. Channel never says "no profile" or "not on Channel", because it does not know that — and neither should anyone watching.

## Carrying for others

When two phones meet, each can carry sealed mail for contacts the other cannot reach yet, and hand it on later. The carrying phone cannot read what it carries. Settings › *Carry for others* controls whether this phone does so.

## What the letters mean

Four letters sit under a contact's name. They do not all mean the same kind of thing.

- **B** Bluetooth, **W** Wi-Fi direct, **L** local network. Lit means their phone is in range of yours **right now**, with Channel awake on it. These go dark when the other person closes the app or walks away, and that is correct: there is nothing there to reach.
- **I** internet. Lit means the mailbox route works: the server is reachable and they have an address you can post to. It says nothing about whether their phone is awake, because with a mailbox that does not matter. You post now, they collect when they look.

Tap the letters in a conversation for the same sentence in the app. Tap again and it goes away.

Channel tries them in that order: Bluetooth first, then Wi-Fi direct, then the local network, then the internet. The first one that carries the message wins, and the mark beside the message says which one it was.


---

<!-- docs/help/relay.md -->

# The relay

The relay is our server. It exists for one reason: to hold a sealed message until the phone it is for comes to collect it.

## What it does

- A sender deposits a sealed envelope under a **token** that only the two paired phones can compute. The token changes over time.
- The recipient's phone asks, on its own schedule, whether anything waits under its tokens, and takes it.
- Envelopes are deleted when collected, or when they expire.

## What it learns

- That *some* phone deposited an envelope of a certain size at a certain time, and that *some* phone collected it.
- The network address of whoever connected, for as long as the connection lasts.

## What it never sees

- Who you are, who you are talking to, or what you said. There are no accounts. Nothing on the relay links a token to a person, a number, or another token.
- Your contacts, your number, your name, or your card.
- Whether a number "is on Channel". A request left at a number is accepted whether or not anyone will ever collect it, so the answer is always the same.

## Choosing not to use it

Settings › Reach › *Internet mailbox* turns the relay off for a profile. Messages then travel only when the phones are near each other, or by way of a phone carrying for you.


---

<!-- docs/help/settings.md -->

# Settings

## This phone, or this profile

Some settings belong to the phone: the radios, quiet hours, notification sounds, the app lock, screen capture, hints, *Wake me when mail arrives*. Others can differ per profile: cadence, the internet mailbox, read receipts, photos, translation, *check only when I open*.

At the top of Settings, **This profile** switches the view. With it off you are setting the phone; with it on, only the profile you are wearing. A profile with no setting of its own follows the phone.

## Reach

Which paths this phone will use. Switching one off hides its light everywhere.

## Fetch new messages

The same choice Apple Mail offers. **Push**: the phone is woken the moment mail lands, once per message. **Every 15 minutes**: the phone is woken on a schedule whether or not anything is waiting, so the timing says nothing about a message. **Manually**: nothing is registered with Apple or Google, and mail is fetched only while the app is open. Below it, how often to check while the app is open; the background wake is one per phone, so the profiles vote and quiet hours win.

## Hints

One-line hints appear the first time a gesture is the only way in — *long press for options* — and retire themselves once you have used the gesture. *Show hints* turns them off everywhere.

## Backup

Off unless you use it, and never silent. *Back up now* seals profiles, identities, contacts, conversations, pictures and settings into one file under a passphrase you choose, and hands it to the phone's own file picker — your cloud drive, a folder, a computer. We never hold the passphrase and cannot recover it. Disappearing and view-once messages are never included; photos and other attachments are not, for size. Whoever stores the file learns that a backup exists, its size, and when it changed. *Restore from a backup* replaces everything on this phone.

## AI

Your own account with OpenAI, Anthropic or xAI: the key stays on this phone and our relay never sees it. With a key saved, a ✦ button joins the composer. Nothing is ever sent to the provider on its own.


---

<!-- docs/help/troubleshooting.md -->

# When something does not work

## The code never arrived

- Check the country code and the digits shown under the number.
- Some numbers cannot receive texts: a landline, a number that lives only in another app, a number a carrier is filtering. Choose **Phone call** instead and the code is read aloud.
- The code can arrive on any device that has the number. It does not have to be this phone.
- Codes expire in ten minutes; ask for a new one.

## A request stays at *Waiting for them*

You accepted, and the other phone has not been reachable since. Pairing finishes when both phones are next reachable — nearby, or both on the relay. You can leave the screen; it is not stuck. Cancel it if you no longer want it.

## Safety numbers do not match

Compare them in person. If they differ, do not send anything sensitive: one side's key has changed, or something sits between you. Pair again by QR code, in person.

## *Invitation left*, and nothing happens

Reaching someone at a number leaves a request in a slot only that number's owner can collect. Nothing tells you whether anyone will. If they have not verified that number in Channel, they will never see it — try another value, or pair in person.

## The same person twice

If a request arrives from a number you already have, accepting it attaches the key to the existing contact rather than making a second one. A second key for the *same* name that you did not expect is worth a question.

## I forgot my app PIN

There is no way in. The PIN is never stored, so nobody — not us — can reset it. Delete and reinstall Channel, then restore from a backup if you made one.

## The banner is the wrong colour, the keyboard covers a field

Tell us. Include the phone model and what you were doing; leave out anything you would not want in an email.
