Help

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:

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

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.

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.

TierSituationBehaviour
1No internet at all — no cellular, no Wi-FiMesh only: proximity and courier. The aircraft case
2Internet present but restricted — client isolation, corporate or captive networksLocal transports plus whatever internet is actually permitted; never assume same-SSID means reachable (SPEC-ORIGINAL §7)
3Full internetAll 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:

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.

Residual risks, stated plainly:

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:

---

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

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

---

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

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:

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

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

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

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

Rules that keep the door open:

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

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

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 → ReceiverVerdict
iPhone → Androidworks — iPhone scans (measured), Android advertises persistently (measured)
Android → iPhoneworks by inversion — Android cannot see a sleeping iPhone (measured broken), but the iPhone finds the Android and pulls
Android → Androidexpected fine
iPhone → iPhoneunknown — 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:

RetrievalServer learnsCost
Rotating token, server matches"token T got mail, T collected it" — epoch-scopedcheap; 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:

MechanismTimelinessCost
Silent push heartbeatgood, throttledAPNs token; server sends
BGAppRefreshTaskpoor for a quiet app — iOS deprioritises apps rarely openedfree, no server
BLE encounter wakeimmediatefree; measured working
NEAppPushProviderpersistent on named Wi-FiApple-gated entitlement
Significant location changereliablerequires location — ruled out
VoIP push / audio background modereliableApp 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

AxisOptionsServer-visible?
PipesBLE / Wi-Fi direct / internet mailbox, each on or offonly as presence or absence
Cadenceon-demand, slow, normalyes — keep coarse
Heartbeatshonour, ignore, honour conditionallyno
Push registrationregister, or neveras presence in the registry
Carrying for othersrelay/courier for traffic not its ownno
TTLhow long own outbound messages may sitas 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:

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 allowedwhether senders may deposit for him at all
Cadencewhen 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

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.

MethodChannelCarries
QRphysical, in personthe full public key
NFCphysical, tapthe full public key
Text codeinsecure — SMS, iMessage, any other appthe 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:

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:

LayerBehaviourConfigurable?
Foregroundpoll every minute or twoNo. The app is open; the user is waiting.
Backgroundthe user's chosen frequency, and the server wakes at that rateYes — 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

RankPathwayWho can observe itSpeed
1Direct Wi-Fi (Wi-Fi Direct / Wi-Fi Aware)someone with a radio in rangefast
2Bluetooth LEsomeone with a radio in rangeslow (KB)
3Shared LANanyone on that network, and the router ownerfast
4Internet mailboxthe relay, by design blindfast

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 LANDirect Wi-Fi
Android ↔ Androidworks — standard IPWi-Fi Direct since 4.0, Wi-Fi Aware since 8
iPhone ↔ iPhoneworks — standard IPMultipeerConnectivity over AWDL
iPhone ↔ Androidworks — it is just IPthe 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:

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

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

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:

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.

TermWhat it meansStatus
RelayUsing another phone as a Bluetooth and/or Wi-Fi range extensionNot built (J1)
ServerThe Netlify service: internet storage for messages not yet pulled by their receiverLive at channelmessenger.netlify.app, deployed 2 Sep 2026
(unnamed)A macOS/Windows desktop app acting as a household relayUndecided, 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

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

ChannelColourNote
BluetoothBlueDon
Wi-FiGreenDon
Server (internet)VioletProposed 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:

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

ChannelColourContrast on garnet
Bluetooth#4FC3F75.87:1
Wi-Fi#69F0AE8.22:1
Server#FFD54F8.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:

---

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

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

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

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:

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:

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:

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.

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

ExposedWhy
Bob's entire message historyStored plaintext on his device; no forward secrecy over stored messages
The ability to impersonate Bob going forwardThey hold his identity key
Bob's contact listWhich 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:

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

FindingClaimedVerified as
Completed handshake destroyed by any later packetHighCONFIRMED. 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 pathsMediumCONFIRMED. There is no impl Drop for Handshake. Live X25519 private material survives every pre-finish failure.
generation attacker-inflatableMedium, framed as contradicting the specReal, 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 machineHighReal, 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 silentlyLowReal 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 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"

#### 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 IJ21'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 NSContactsUsageDescriptiondo 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

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

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:

AppModel
ThreemaOne-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
SignalNonprofit; donations, seeded by a $50M loan from Brian Acton
Wire, WickrPivoted to enterprise/government; Wickr acquired by AWS
WhatsAppCharged $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.

TierWhat it actually proves
QR + confirmation code compared aloudThey were together. The comparison does the work, not the scan.
QR aloneA camera saw a code. Possibly a forwarded photo.
Link or code via another channelAs strong as that channel; whoever controls it can substitute
Number rendezvousControl 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:

WhereWhat
The badgeDoes 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 choosingOne 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 documentationThe 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.

MethodWhat it provesWhere it is weak
QR in personThe camera saw the real screenNothing meaningful
Link you sendOnly as strong as the channel it travelledWhoever controls that channel can substitute
Number rendezvousWhoever controls that numberOur 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.

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:

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:

#### 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?
AndroidYes. 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.
iOSNo. 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 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 unpublished 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:

BoundWhy
Hop limitA 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 suppressionA 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 neighbourOtherwise 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 APIInteroperates with the other?
iOSNo 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
AndroidWifiP2pManager, i.e. Wi-Fi Direct properNo

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:

RoleAndroidiOS
Raise a hotspotYes — WifiManager.startLocalOnlyHotspot, API 26+No. Personal Hotspot is user-initiated with no API
Join oneYesYes — 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.

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

ClaimWhat makes it true
Not persistedPhoneContactBrowser holds state in memory; nothing is written to storage
Gone when the screen closesclear(), called on selection and on Done, with a test that fails if state survives it
Not uploaded, not hashedNo code path takes it anywhere; J38's "no contact matching, not hashed" still holds
Minimal in the first placeThree 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

LayerRate
App openExactly what was chosen. The app owns its own clock here, which is why these labels can be exact where the others hedge.
App backgroundedThe same choice, floored at fifteen minutes
Check NowImmediately, 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:

#### 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 overReachWhat is disclosed
BLE~30 ftNothing — no infrastructure involved
Wi-Fi LAN~100+ ftWhoever runs that network sees a brief exchange
ServerAnywhereA 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 overWhat it proves about distance
BLEWithin ~30 ft. A Wi-Fi handover will almost certainly succeed.
LANSame network — one room, or a whole building.
ServerNothing. 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:

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

PairMechanismCosts the user
iOS ↔ iOSAWDL via NWParameters.includePeerToPeerruns alongside infrastructure Wi-FiNothing
Android ↔ AndroidWi-Fi Direct on its own virtual interfaceUsually nothing; older devices may drop Wi-Fi
MixedSoftAP: Android hosts, iPhone joinsThe 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:

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:

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

ConstantValueWhy this number
ATTACHMENT_CHUNK_BYTES16,000Largest 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_BYTES1,048,576A generous ceiling for a photo, not a target. Compression aims far lower.
MAX_ATTACHMENT_CHUNKS66Derived from the two above, never written independently, so they cannot drift apart.
MAX_ATTACHMENTS_IN_FLIGHT8Bounds the assembler, mirroring MAX_REASSEMBLY_BUFFERS.
MAX_ENVELOPE_BYTES32,768, unchangedThe 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:

MessageBucket without compressionActually sentSaved
10–160 B2562560
200–300 B1024256768
500–620 B102410240

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:

ProvesComparable
Confirmation code (J52)the key agreement was cleanonly during pairing
Safety number (J62)the keys matchat 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 │ └─────────────────────────────┘

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

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

ButtonMechanismPermission needed
Choose One from ContactsCNContactPickerViewControllernone — runs out of process, returns one name
Browse My ContactsCNContactStore (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:

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:

EnumerationConvenienceClaim cost
(a) Directory, WhatsApp-stylemembership list falls outhighestloses "no phone number" outright
(b) Code-gated, J40 as writtenimpossibleneeds an out-of-band codenone
(c) Write-only discoverydeposits possible, membership not observablehigh"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 youRequires
Verified handlesomebody can find youSMS/email proof of control
Key exchangenobody can interposein-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.

  1. Nothing blocks on verification. Ever. No modal, no "verify before messaging", no disabled send button. The message goes.
  1. 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.
  1. 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

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

ScopeWhy
Verified — this device proved control of +1-555-1234device-wide, reused freelya local fact that leaks nothing; re-proving it per profile is friction with no benefit
Exposedthis profile is findable at +1-555-1234per profile, and at most onepublishing 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:

KindEnforced byExampleIf we are lying
Architecturalmathematicsthe relay cannot read your messagesprovable by anyone with the protocol
Policyour configuration and our wordwe do not log IP addressesundetectable from outside
Aspirationnothing yetwe would resist a demandworthless 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

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

AdversaryHow we do
Advertisers, data brokersThey get nothing. No SDKs, no identifiers, no telemetry to buy — structural, not policy.
Someone on your Wi-FiCovered. 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 phoneScreen lock (AppLock) helps and is honest about being a screen cover, not a second layer of encryption.
Your ISP or a network observerContent 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 usLittle to hand over, and less for a key-only profile than a handle-bearing one (J68).
A subpoena to Netlify, Telnyx, Apple, GoogleOutside our control entirely.
A nation state with device accessWe lose, and so does everyone else. Say it plainly.

#### What we do not control, named rather than gestured at

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

AndroidiOS
Block screenshotsFLAG_SECURE — genuinely prevents itno official API, but the secure-layer technique works — see correction below
Block screen recordingFLAG_SECURE covers itsame technique covers it
Hide from the app switcherFLAG_SECURE covers itpossible by other means
Detect a screenshot after the factnouserDidTakeScreenshotNotificationafter, never before
Detect screen recordingnoUIScreen.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

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

ScreenBlocked?Why
Expanded profile photoyesDon, on where WhatsApp actually does it: "it was the profile image after i expanded it"
Avatar thumbnail in a listnoincidental at that size, and blocking it would black out whole list screens
Contact info (the card itself)noDon, testing it: "they let me screen capture the entire contact card that includes the little profile pic but not the expanded version"
View-once mediayesthe entire feature is void without it
Your own pairing QR / codenopeople legitimately screenshot it to send by another channel, and we support sharing it as text anyway
Safety numbersnocomparing 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:

ScreenTodayShould be
New chatlist only, no fieldone field
Contactsno search at allthe same field
Add contactseparate "Add by Name" and pairing routesthe same field

One control, in all three, doing one thing: you type, and it narrows or resolves.

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-controlledMust never be
TTLs, rate limits, poll cadence hintswhich cipher or key agreement is used
Minimum-supported-version noticeskey lengths, padding buckets that affect confidentiality
Endpoint routing and retry policywhether a message is encrypted at all
Feature flags for UI and product behaviouranything 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:

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

##### 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 sendPoll everySince last receivePoll every
< 5 min1 min< 2 min1 min
< 15 min2 min< 6 min2 min
< 40 min5 min< 20 min5 min
< 90 min10 minafterfloor
afterfloor

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:

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.

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

SettingScopeHow several profiles combine
Internet (relay)per profilegates only that profile's mailbox polling
Check cadence, quiet hoursper profilevote: 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 soundper profileno combining needed — each applies to its own conversations
Bluetooth, local networkdevice (one radio)vote: any profile on turns the radio on; only the selected profile answers on it
Relay for othersdevicevote: any
Block screenshots, app lock, merged view, Scope itselfdeviceone 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:

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

AppTakes a numberWhat happensNotes
Phone dialertel: (both)Dials after the OS asks (iOS) / opens the dialer filled in (Android ACTION_DIAL)No permission needed for ACTION_DIAL.
WhatsApphttps://wa.me/<digits> (both), whatsapp://send?phone= (iOS)Opens the chat; call button is one tap furtherNo official direct-call URL; unofficial ones break.
FaceTimefacetime://<number or email>, facetime-audio://Starts the call (iOS only)Not on Android; the FT mark is hidden there.
Signalhttps://signal.me/#p/+E164 (sgnl:// on iOS)Opens the chatFragment, so the number never reaches Signal's server.
Telegramhttps://t.me/+E164, tg://resolve?phone=Opens the chat, subject to their privacy setting
Google Meetmeeting codes onlyNo documented number-carrying 1:1 scheme any more; not offered.
Zoomzoomphonecall:// (Zoom Phone licence only)DialsConsumer Zoom is meetings-only; not offered.
Skype / Teamsskype:+E164?call, Teams deep linkDialsNiche; 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

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

##### J89. View-once pictures, as built. — Claude, 4 Sep 2026, from J70

##### J90. Direct Wi-Fi, as built -- and what is not yet known about it. — Claude, 4 Sep 2026, from J21 rank 1

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

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:

##### 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 / CanadaMost of Europe, LATAM, APACThe expensive tail
Telnyx SMS, per messageabout $0.004-$0.01about $0.01-$0.06India, Nigeria, some Gulf carriers: $0.06-$0.20+
What $200 a month buys20,000-50,0003,000-20,000as 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:

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

#### The costs, stated before anyone builds it

#### 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 itRunsLooks designed for it
iPadyes -- TARGETED_DEVICE_FAMILY "1,2", all four orientationsyes, verified 4 Sep on an iPad Pro 11-inch simulatorno
Android tablet / Chrome OSyes -- the Play track already lists Phones, Tablets, Chrome OS, Android XRexpected (no tablet-only code), untestedno

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:

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.

  1. 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.
  2. 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) │ │ └────────────┴───────────────────────┴──────────────┘

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

MenuItemsShortcut
FileNew Chat · New Contact · Check Now⌘N · ⇧⌘N · ⌘R
Editthe platform's standard editing items--
Profileswitch profile (list) · Manage Profiles⌘1…⌘9
ViewMessages · Contacts · Settings⌘⌥1…3
HelpHow 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

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

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

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:

StateShown asWhat it actually means
Never pressedReachWe have not tried.
Pressed, nothing backWaitingEither nobody holds that value, or they hold it and have not opened the app. We cannot tell, and neither can an attacker.
AnsweredLusmar (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:

---

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:

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:

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.

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.

---

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 aroseTierWorth
Scanned or compared in personVerifiedTwo people compared a safety number.
Introduced by a verified contactIntroducedAs good as the introducer, and no better.
Reached by number or address (J104)Not verifiedSomebody 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.

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.

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

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

### 5. What WhatsApp does, since Don asked

Fairly: less than this.

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.

---

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:

---

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:

---

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.

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:

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

---

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:

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

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.

WhereSwipe left (trailing)Swipe right (leading)
Conversation rowDelete (confirmed) and ArchiveMark unread
Contact rowDelete contact (confirmed)Message
A messageDelete for meReact

Two rules run through all of it:

---

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.

ItemUsWhy
PhotosYes, already builtThe system picker, out of process; the app never holds a photo permission.
CameraYesSame shape: capture and hand back one picture. Needs a FileProvider on Android for a full-size result rather than a thumbnail.
DocumentYes, laterThe attachment transport already chunks (PROTOCOL.md payload type 6); what is missing is generic file support, since today it is images only.
ContactYes, laterThe 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.
LocationNo, for nowIt 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, EventNoReal features, unrelated to anything this app does yet.
AI imagesNeverJ111: 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:

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

---

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:

### 2. What this unlocks, including one thing we had ruled out

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

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

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

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.

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

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

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:

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

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:

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:

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:

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:

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

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:

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:

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:

### 3. The setting, once it exists -- and Don's "secret agent" profile is the right shape

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

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.

---

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:

ChannelStatusWhy
Text messagebuiltThe default where it works, and iOS autofills the code (J123).
Voice callnext, and cheapTelnyx 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.
WhatsApplater, and not cheapMeta'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.
EmailbuiltAlready 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:

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

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

### 5. What else Meta offers that is worth taking

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

Two details that matter more than the voice does:

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

### 4. Two traps in the sending code, both silent

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

---

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:

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

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) / 360the 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:

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:

Blocking a screen with nothing in it protects nobody and teaches the user the app is broken.

### 2. The policy

ScreenBlock?What the capture would carry
ConversationYesThe messages. This is the reason the feature exists.
Photo / attachment viewerYesThe picture itself, and view-once has no meaning without it.
Pairing — my QR / codeYesA pairing code is single-use and rotates; a picture of it is a pairing attempt anyone can start.
Pairing — confirmation codeYesTwelve digits that, photographed beside the other phone's, prove nothing about this exchange.
Contact cardYesNumbers, emails, the safety number, the photo — the whole identity, on one screen. WhatsApp blocks exactly this one.
Messages listNoNames and previews. Worth a lock, not a shield; blocking it blocks the screenshot every bug report starts with.
Contacts listNoNames. Same reasoning.
Settings, Help, Profile sheet, RequestsNoNothing a stranger could use.
Verify sheetNoThe number being verified, which the user just typed themselves.
App switcher snapshotYes, alwaysWhatever 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. J141J143 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:

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 keysmyDestinationTokens(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:

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.

---

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.

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:

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:

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)

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 J155J175, 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

### Sender

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

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

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

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:

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:

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

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.

English is the reference text; any translation is a convenience. Help is stored on this phone and never contacts anyone.