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).
---
### 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:
SMAppService so it is visible and removable in the OS's own Login Items UI. Windows: per-user startup or logon task, never a Service.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.
| Tier | Situation | Behaviour |
|---|---|---|
| 1 | No internet at all — no cellular, no Wi-Fi | Mesh only: proximity and courier. The aircraft case |
| 2 | Internet present but restricted — client isolation, corporate or captive networks | Local transports plus whatever internet is actually permitted; never assume same-SSID means reachable (SPEC-ORIGINAL §7) |
| 3 | Full internet | All routes. Local preferred, internet last — not because it is worse functionally, but because it is worse for privacy |
Tier 3 is the common case and the spec was wrong to treat it as exceptional. This needs no new machinery: route cost already penalises the gateway hop at 120, so "prefer local, fall back to internet" falls out of scoring. The UI must show which tier is live, per SPEC-ORIGINAL §29 — the individual transport states, never a generic "offline".
### A6. A mailbox and notification service, operated by Don. — Settled by Don Reverses part of A1. Recorded with the reasoning, because the reversal is deliberate.
Why. A1's no-server position served proximity and courier delivery, and left everyone else with "install a desktop app" — which most people will not do. Don: "not everyone will install a desktop app - most will not bother certainly." A privacy tool that cannot deliver a message is not a privacy win, and the user-operated node (A3) is a minority answer to a majority problem.
What it holds, precisely. Encrypted envelopes only, plus a rotating destination token, a bucketed size, an upload time, a download time, and two IP addresses. It cannot decrypt anything, ever — a content request returns ciphertext, and that is checkable rather than promised. Tokens rotate every ten minutes and there are no accounts, so nothing durable attaches to a pattern.
Mandatory mitigations — these are the design, not nice-to-haves:
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.
F1).D6): Alice choosing the server exposes Bob, who did not choose. Same asymmetry as the gateway hop (D7).Residual risks, stated plainly:
EXPORT-COMPLIANCE.md, and the frameworks noted in A1). Needs counsel before shipping into the UK or EU.B1 amended in consequence. Targeted push was a permanent non-goal because there was no server. With a mailbox, the server already knows a message exists for a token, so push metadata is strictly less than what the mailbox reveals. Refusing push now would pay the cost and decline the benefit. Targeted push ships with the mailbox, under the same opt-in and the same recipient veto.
### A8. Inviting and pairing are client actions. The server stays out of both. — Settled by Don Don: "if a user wants to invite someone to the app that's a client action... the server doesn't have to have anything to do with either route and should stay out of it." The concrete form of A7.
Two stages, and only one carries secrets:
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:
A7 exists to deny us.---
### A9. Satellite direct-to-cell is closed to us. — Noted, researched 2026-09-01 Don has T-Mobile T-Satellite (Starlink Direct to Cell) on his Android. The obvious idea — one satellite-capable phone acting as the mesh's uplink for a group with no coverage — is architecturally sound and practically unavailable.
Why. Android blocks unoptimized application traffic over an NTN link at the network-policy layer, using firewall chains and data-saver extensions, and the allow-list is controlled by carriers and device manufacturers. Apps cannot bypass it. Getting on that list is a commercial arrangement with T-Mobile and SpaceX, not an engineering task. (Verified against AOSP's own satellite documentation. A claim that an app can self-declare via a PROPERTY_SATELLITE_DATA_OPTIMIZED manifest property did not verify there — treat it as unconfirmed.)
What is live today (September 2026): SMS, MMS, RCS, location sharing and text-to-911, plus data for a curated set of partnered apps. Roughly 2–4 Mbps, needs clear sky. Native voice and general data are roadmap, tied to later satellites.
The one to watch is AST SpaceMobile. It uses partner spectrum and presents as ordinary LTE to an unmodified phone, so third-party traffic should route transparently with no allow-list — which is exactly what T-Satellite denies us. Not commercially live; targeted early 2027. If that ships as described, the gateway idea below becomes available with no new protocol work.
Apple's satellite (Globalstar, iPhone 14+) is Emergency SOS, Messages and Find My only. No general data, no third-party access.
Nothing to build now. But when a transparent direct-to-cell service does arrive, it needs no new mechanism: it is D7's gateway hop with a satellite backhaul, and the route cost already prices a gateway at 120 against BLE's 40, so local paths keep winning and satellite is used only when nothing else reaches. The fit is unusually good — tiny bandwidth is fine for a 256-byte padded message, high latency is fine for a protocol built around messages that wait, and a mesh aggregating many people onto one scarce link is the best possible use of it.
And the same caution applies as any gateway: satellite routes through a carrier, which is an intermediary with billing records. Opt-in, off by default, per contact, tried last, recipient can refuse (D6, D7).
### A7. The server MUST NEVER be in the key-distribution path. — Settled A hard line, recorded while it is still free.
An end-to-end operator cannot be compelled to decrypt. They can be compelled to hand someone the wrong key, if they are ever the party distributing keys. Key custody is not the risk; key distribution is. It is the one attack that turns "we cannot decrypt" from a structural fact back into a promise.
Today this holds by construction: pairing is QR in person, or a blob the user carries over a channel we do not operate (E4).
The danger arrives with the server. Once infrastructure exists, the obvious next feature is letting it help people pair — hosting invites, looking up contacts, smoothing onboarding. That is the moment we become a party who could substitute a key.
Therefore, normative: no public key, pairing blob, invite, or any key material may pass through our infrastructure, in any release, for any convenience. The mailbox carries encrypted envelopes and nothing else. A feature request that violates this is refused regardless of how much friction it would remove.
---
### B1. Notification ladder — local wake first, broadcast push at most, targeted push never. — Settled
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.
---
### C1. AAD is a fixed-layout byte concatenation, not a CBOR re-encoding. — Settled Authentication must not depend on canonical CBOR.
Why. If AAD is "the CBOR encoding of the header," two implementations that order map keys differently compute different tags and every message fails authentication — surfacing as "authentication failed," which points a developer at the crypto, where nothing is wrong. With a fixed-width, fixed-order AAD, a CBOR disagreement produces a visible parse error instead. This removes an entire class of silent cross-platform divergence from the security-critical path.
### C2. Canonical CBOR is pinned to RFC 8949 §4.2.1. — Settled Bytewise lexicographic ordering of encoded keys. The older RFC 7049 length-first ordering is forbidden by name. Integer map keys, not strings. Shortest-form integers. No indefinite-length items.
Why. "Canonical CBOR" is ambiguous — two competing standards disagree on key order and libraries default differently. Picking one without naming it is how you get the exact bug you were avoiding.
### C3. destinationToken and presenceToken are separately derived. — Settled Same pairwise secret and epoch, different HKDF labels. ROUTE_QUERY carries the presence token, so discovery works off relays' beacon caches. The ENVELOPE carries the destination token and is forwarded along reverse-path state by query ID, never by token matching.
Why. If they were one value, a relay that heard a beacon could link it to a message it is carrying. Courier mode still works because it runs on inventory exchange, not token recognition. Cost is one extra HKDF call.
### C4. Mutable fields are excluded from AAD; immutable ones are included. — Settled
hopLimit — excluded. It decrements at every relay; including it would break the tag on the first hop.transitPolicy — included. Immutable end to end, so tampering becomes detectable by the recipient. Zero extra bytes.Consequence. Hop count is unauthenticated and adversarially mutable — a relay can decrement it faster (denial) or raise it (amplification). Pairwise keys cannot prevent this because relays share no secret with the sender. Defence is local policy only: each node's own ceiling, storage caps, and per-encounter rate limits. This must be stated plainly in THREAT-WALKTHROUGH.
### C5. Pairing generation counter. — Settled routingSecret = HKDF(sharedSecret, "routing-v1" ‖ generation). Re-pairing bumps the generation and every previously derived token dies instantly; conversation history survives because it is keyed separately. Deleting a contact destroys the pairwise secret, making anything still in flight permanently undecryptable — including by us.
### C6. Bucketed padding is mandatory; compression is optional inside it. — Settled Pad to coarse buckets (256 B / 1 KB / 4 KB / 16 KB / 32 KB). Compress within the padding if desired.
Why. payloadLength is visible to every relay, and compress-then-encrypt makes ciphertext length reflect content entropy — the CRIME/BREACH family. Chosen-plaintext injection is hard here, but the weaker leak is real: stock phrases compress to recognisable sizes. Padding fixes both the raw-length side channel and the entropy leak. Note that padding and compression partly spend each other; padding is the one to prioritise.
Details:
CompressionDictionary v1.### 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.
---
### 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."
min() is not universally the strict operator. For hop limit, retention and disappearing timers, stricter is smaller. For padding bucket size, stricter is larger. PROTOCOL.md carries an explicit table of field → direction → operator. No inference.### 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.)
---
### E1. Three pairing tiers, with verification as a permanent visible property. — Settled
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.
---
### 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:
PROTOCOL.md + vectors/ — language-neutral.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.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):
ITSAppUsesNonExemptEncryption = YES; French declaration if shipping to France; no CCATS on the industry-standard-algorithm reading.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:
### 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:
SPEC-REVISION §1.2). Add to CI targets alongside iOS and Android.SPEC-REVISION §1.9).A3 unchanged, including the wake-beacon role.F10) there is no cross-platform entitlement to link at all: the paid product is iOS and Android, each carried invisibly by its own store, and no account is needed anywhere.### F10. The desktop app is free. — Settled by Don Don: "a pc install is both a relay / launchd server / and a client all in one and is optional and could be free or not perhaps."
Decision: free. It does two useful things at once.
It removes the licensing problem F9 created. With no paid desktop there is no cross-platform entitlement to link, so the paid product is iOS and Android — each carried invisibly by its own store, with no account anywhere and no offline licence token needed. Windows being in scope stops constraining the pricing question entirely.
It seeds density when density is worst. The mesh has no value at zero users, and paid-only at launch guarantees minimum density exactly when it matters most. A free always-on relay anyone can install is the cleanest answer: infrastructure is free, the phone is the product. A desktop node is relay, persistent mailbox, wake beacon (PROTOCOL.md §7.8) and full client in one.
---
### 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.
---
(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.)
---
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.
---
### 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 → Receiver | Verdict |
|---|---|
| iPhone → Android | works — iPhone scans (measured), Android advertises persistently (measured) |
| Android → iPhone | works by inversion — Android cannot see a sleeping iPhone (measured broken), but the iPhone finds the Android and pulls |
| Android → Android | expected fine |
| iPhone → iPhone | unknown — the critical path |
Every case resolves the same way: the iPhone must always be the party that scans and initiates. Android then never has to find a sleeping iPhone, so the one thing measured broken never happens.
The two untested links, both measurable with the existing probe:
bluetooth-peripheral background mode says yes; also unmeasured by us)If both hold, queued phone-to-phone delivery works on iPhone with no server and no relay. If link 1 fails, two iPhones cannot find each other and iPhone-to-iPhone requires one app to be open — a real limitation to establish before a store listing rather than after.
### J6. The mailbox buffers, expires, and never bounces. — Don's design, one part corrected Don: "perhaps netlify software needs to send the push and the message might be sitting on the net as the sender might lose it's connection before the message leaves the sender device otherwise so the server would be a buffer that holds and then sends and maybe has to hold forever unless the user specifies a TTL for messages in transit with a return to sender perhaps if a message times out."
Three of the four already exist in server/netlify/functions: deposit/collect/confirm with delete-on-confirmation, a MAX_AGE_MS ceiling, and wake() stubbed under a hard requirement that the payload carry no content, no sender and no conversation identifier. The fourth must not be built.
Return-to-sender cannot work and is rejected. The server does not know who sent an envelope — that is sealed sender, and it is the point. Bouncing one would require the sender to identify itself at deposit, handing the server precisely the correspondence record the design exists to withhold. That trades the core property for a delivery receipt.
Replacement, same user experience, no cost: the sender notices. The sending device keeps its own copy and polls whether its receipt is still uncollected. The server answers yes or no and learns only that someone asked about that receipt, never who. The user sees "not delivered" exactly as a bounce would show, and the retry decision stays on the device. Check marks in every other messenger are client-side state already.
Push defeats token rotation — state this plainly to users. The server must be told a stable notify id to reach a device. Destination tokens rotate; a notify id cannot, or the push stops arriving. So the server can link every rotating token that appears alongside the same notify id. Enabling push creates a durable pseudonym at the server — not a name or an address, but a thread tying a user's conversations together. There is no engineering fix: wake-on-message requires a stable handle by definition. It is a genuine fork, and it is exactly the risk-tolerance choice Don described — push on and the server holds a pseudonymous identity; push off and it holds nothing durable, but messages arrive when the app is opened or a peer is met in person.
TTL: the argument for short is legal, not storage. Data held is data that can be demanded — see LEGAL-RISK.md. The current 24-hour ceiling is likely too aggressive for a courier product where a recipient may not open their phone for a weekend.
Proposed, pending Don: user-selectable one hour to 30 days, default 7 days, 30 days as a hard policy cap no user or operator can raise. The cap is the protection. It is the difference between "we do not keep it" and "we keep it until someone asks."
### J7. Oblivious mailbox — the server should not know whose envelope it holds. — Don's design, accepted with caveats, deferred past v1 Don: "the server doesn't have to know who the envelope belongs to… the internet enabled device just polls the server decrypting it's id so the server serves the right envelope and then removes all trace of it. at storage only the receiver can tell if there is mail for him when he polls with his key."
This is private information retrieval, and the construction is well studied.
What it buys, stated precisely. The current scheme leaks something not previously flagged: deposit and collection both occur under the same rotating destination token, so within one rotation window the server can link "envelope deposited for T" to "T collected it." Identity stays hidden; the correspondence event does not. J7 removes that link.
Cheap form. Attach a short detection tag to each envelope rather than trial-decrypting bodies. The recipient downloads all tags and tests them against their key. At 16 bytes per tag: 10k envelopes in flight is a 160KB poll, 100k is 1.6MB. That is the practical ceiling and it is far beyond where this product needs to work.
The catch, which is the part that bites. Downloading every tag is private. Fetching the match is not — pulling envelope #4417 tells the server that one was yours, and when it was deposited. The leak moves rather than disappears. Fix: fetch decoys alongside the real one, so the server sees a k-anonymity set instead of a point.
Growth path, if scale ever demands it. Reveal a few bits of the address so the server can bucket: a 2^-k anonymity set for a 2^k reduction in download. Privacy becomes a dial rather than a cliff. This is fuzzy message detection (Beck, Len, Miers, Green). Worth knowing it exists; not worth building now.
Where J7 does not help: the nudge. A server that can wake one specific device needs a stable handle, so the J6 pseudonym returns intact. Either push to a k-set of devices when any one has mail — waking 50 phones to deliver to one, a real battery cost — or drop push and poll on a schedule. Scheduled polling is the most private design available, but iOS throttles background refresh at its own discretion, so no interval can be promised on the platform that matters most.
Sequencing: deferred past v1, safely. The tag is a transport-layer field. The sealed envelope, the AAD layout and the crypto core are untouched, so this lands later without a migration. Shipping must not block on it.
### J8. Delivery policy is per-contact, and a policy change is itself a message. — Don's point, with a wrinkle Don: "both ends would need to be able to know this to keep an internet enabled sender trying to send a message to a receiver that forbids it."
Decision: each contact record carries the peer's declared allowed pathways, exchanged at pairing and updatable. This is the machinery C7's stricter-side-wins rule needs — that rule cannot be evaluated without knowing the other side's policy.
The wrinkle. A policy change is itself a message and needs a path. If a user disables every internet route, the notice that they did so can travel only by direct radio. A sender never again in radio range will keep queueing against a route the recipient has closed.
Therefore: a stored peer policy must carry an expiry, not be treated as current indefinitely. On expiry the sender falls back to the stricter assumption rather than the last one seen. Failing closed is the correct direction: the cost is an undelivered message, and the cost of failing open is delivery over a path the recipient deliberately shut.
### J9. Polling instead of push removes Apple and Google from the trust model. — Don's insight, accepted Don: "by polling the server you eliminate the normal notify thing… to do a server push the server has to know in advance who it belongs to so changing to a poll eliminates that knowledge and cuts apple out of the loop (or google)."
This is the strongest privacy claim the product can make and it should be treated as a headline, not a setting. No APNs token, no FCM registration, no third party that learns the app is installed on that device or when it receives anything. Push does not merely leak a pseudonym to our server — it inserts Apple or Google as a party who sees delivery timing for every message. Polling deletes that party.
One correction to the mechanism. Don proposed the server match a polled key against stored envelopes. If the receiver polls with a value the server matches on, the server sees that value on every poll — it can count that recipient's mail, time it, and link every envelope that ever matched. Server-side matching is server-side knowledge, and the J6 pseudonym returns in different clothes.
Unless the value rotates — which is exactly the existing destination token. So Don's design is sound and already built; the residual leak is epoch-scoped, and the token is meaningless once rotated. The two viable options, both of which eliminate push:
| Retrieval | Server learns | Cost |
|---|---|---|
| Rotating token, server matches | "token T got mail, T collected it" — epoch-scoped | cheap; already built |
Client scans detection tags (J7) | "someone polled" | ~160KB/poll at 10k envelopes in flight |
The hard part is timeliness on iOS, and it does not have a good answer. BGAppRefreshTask runs when iOS chooses, and iOS deprioritises apps the user rarely opens — precisely a messenger sitting quiet waiting for mail. The mechanisms that would give reliable wakeups are silent push (defeats the purpose) or significant-location-change (requires location permission, which is unthinkable in this product).
State it honestly in onboarding: on iOS, poll-only delivery means messages arrive when you open the app, plus opportunistically — not "within fifteen minutes." Android is fine; WorkManager holds a reliable 15-minute floor.
Two things soften this more than they first appear. Direct radio delivery still wakes the app immediately — measured — so poll-only is slow on the fallback path, never the primary one. And polling while the radio is already up is nearly free; the battery cost is cellular cold-start, which is why "poll when you have internet" is the right trigger rather than a fixed clock.
### J10. The heartbeat push carries no information. — Don's design, accepted Don: "the silent push isn't to tell you you have mail, it's to arouse you so you poll to see if you have mail and maybe you do maybe you dont - it's just a heartbeat wakeup once an hour or once every half hour."
Decision: silent pushes are unconditional heartbeats on a clock, never mail notifications.
The gain is structural, not cosmetic. Today deposit.mts accepts a notify id and looks it up to wake a specific device — that lookup is the correlation, and it is what creates the J6 pseudonym. With a blind heartbeat, deposit never touches the registry. The server then holds two things that never meet: a list of device tokens it pings on a clock, and a pile of sealed envelopes under rotating tokens. Nothing joins them, including for anyone who compels the entire server.
Apple learns nothing new. They already know the app is installed — they sold it. What targeted push would have given them is delivery timing: when a user receives messages, how often, at what hours. An unconditional heartbeat carries none of that, because it fires identically whether mail exists or not. The signal is decorrelated from the fact worth hiding, which is the whole trick.
Caveat: Apple throttles content-available. The budget is explicit, adaptive to user engagement, and the system may coalesce or delay. A half-hourly heartbeat is likely within it. It degrades rather than breaks — a late heartbeat is a late poll, not a lost message.
Answering Don's question — the server is not the only possible trigger, but the alternatives are weak:
| Mechanism | Timeliness | Cost |
|---|---|---|
| Silent push heartbeat | good, throttled | APNs token; server sends |
BGAppRefreshTask | poor for a quiet app — iOS deprioritises apps rarely opened | free, no server |
| BLE encounter wake | immediate | free; measured working |
NEAppPushProvider | persistent on named Wi-Fi | Apple-gated entitlement |
| Significant location change | reliable | requires location — ruled out |
| VoIP push / audio background mode | reliable | App Review rejects it, correctly |
BLE wake composes for free. The app already wakes on peer encounters, so proximity to any node — including the user's own laptop — can also trigger an internet poll. Someone who walks past a friend collects from both paths at once, at no extra cost.
NEAppPushProvider is the dark horse. A persistent background extension on specified Wi-Fi networks would give timely polling at home and at work with no push and no Apple in the loop at all. Apple gates the entitlement case by case, historically for local messaging where APNs is unavailable — a fair description of this app. Worth an application; not worth planning around until granted.
Don's framing usefully narrows the problem. Foreground polling is unconstrained, so an active conversation can poll as fast as it likes. Background timeliness is needed only for a thread starting while the app is closed, and 15–30 minutes for that matches what people accepted from SMS for twenty years.
### J11. Server economics, and where a privacy tier is allowed to differ. — Don's concern, with a line proposed Don: "how well this scheme scales in terms of server resource… not all our customers are 007 / James Bond and their privacy/utility bar is a bit different… we will allow security max settings but also will need more relaxed modes to keep from losing money on server work costing performance and $$."
The heartbeat is nearly free; the polls it triggers are the entire cost. Pushing to 10,000 devices is one function run multiplexed to APNs. Those pushes then cause 10,000 inbound requests, each a billed invocation. Suppressing one heartbeat suppresses ten thousand polls.
Therefore Don's "no mail for anyone → wake nobody" is the highest-leverage optimisation available, and not by a small margin. Its privacy cost also shrinks exactly as it becomes necessary: a heartbeat then means "someone, somewhere, has mail," which is a thin anonymity set at 100 users and noise at 10,000. The optimisation gets safer as the system grows — the opposite of the usual tradeoff.
Opt-outs come off the registry entirely. A user who forbids internet delivery can never have mail and must never be pushed. Ordering matters on the yes→no transition: deregister first, then drain, or an envelope deposited between drain and deregistration is stranded. Even then it is not lost — under J6 the sender polls, sees it uncollected, and can deliver by radio later — but the ordering is invisible until someone loses a message.
BLE-triggered polls now have an economic justification as well as a latency one (J10): every poll prompted by a peer encounter is a poll the server did not have to provoke.
#### "Tier" is the wrong word — these are independent preferences
Don: "tier is the wrong word as in my use tier is just a different set of individually chosen preferences as to what pipes are available and how often his device will poll as it can ignore silent pushes too."
Correct, and the distinction is substantive rather than cosmetic. A tier is a bundle handed out, and bundles create classes. Independent axes do not: if each user picks pipes, cadence and push-handling separately, there is no named group to belong to.
This strengthens Don's rebuttal further. The only preference with a server-visible shadow is cadence — and cadence has an innocent explanation. People set polling intervals for battery life. A coarse "check often / check rarely" control is mundane; a setting named maximum privacy is a beacon. Same mechanism, opposite signal. Therefore these must never be presented as a privacy ladder in the UI, even if the underlying options are identical.
The device may also ignore silent pushes. iOS hands the push to the app and the app decides whether to act. Deregistering is strictly better for a user who will never honour a heartbeat — it skips the wakeup cost too — but conditional honouring (only on Wi-Fi, only during waking hours) is a real setting, and it keeps the decision on the device where the server cannot observe it.
#### The axes, all independently chosen
| Axis | Options | Server-visible? |
|---|---|---|
| Pipes | BLE / Wi-Fi direct / internet mailbox, each on or off | only as presence or absence |
| Cadence | on-demand, slow, normal | yes — keep coarse |
| Heartbeats | honour, ignore, honour conditionally | no |
| Push registration | register, or never | as presence in the registry |
| Carrying for others | relay/courier for traffic not its own | no |
| TTL | how long own outbound messages may sit | as expiry metadata |
Carrying for others deserves its own explicit consent, not inclusion in a general privacy setting. It costs battery and storage, and it places data on a person's device that they did not author — a different kind of decision from how often to check mail.
#### Where a preference may and may not change confidentiality
Don is right that most users fear an ex-partner or an employer's lawyer rather than a government, and that building only for the 007 case ships a product nobody buys. But tiering privacy has a trap, and it is the Tor problem: if only 2% of users run maximum privacy, membership in that 2% is itself the signal, and the strict tier stops protecting the people who most need it.
Don's rebuttal, which is correct and narrows this sharply. Don: "the 2% guy maybe has internet turned off as well… so only the guy with the high security really knows he's the guy with the high security as the settings are on his phone."
The Tor problem requires everyone on one network behaving differently. A user with internet delivery off is not on the network at all — he never registers, never polls, and the server holds no record to distinguish. Absence is not a marker when the server never knew he existed. The analogy above was wrong for that case.
It also makes strict mode simpler, not harder. A user with internet off exercises no server code path whatsoever: nothing to build, nothing to audit, nothing that can leak through a bug. And it upgrades the claim from "we do not log you" to "you never contacted us" — categorically stronger, and the only version still true after the server is subpoenaed.
Where the concern does survive, much more narrowly: among users who do use the server, poll frequency is observable. Someone polling every two hours while everyone else polls every thirty minutes is a visible class. Bounded — the mailbox is blind, so it is a timing pattern attached to rotating tokens rather than to a person — but it is the one place a setting becomes a server-side signal. Keep cadence options coarse for this reason; a fine-grained slider fingerprints people.
And the governing framing is Don's: "more secure by nature means less deliverability and vice versa." This is self-enforcing rather than something the product must police. The strict user is not getting a worse product — he is getting exactly what he asked for, and paying in latency rather than in trust. That is a better structure than most privacy settings, which ask a user to give up something invisible for a benefit they cannot verify.
Consequence to keep visible in the UI: with C7 stricter-side-wins and J8 per-contact policy, a sender queueing for a radio-only contact may queue forever if they never meet again. Inherent and correct — it is what the recipient chose — but the sending interface must make it legible, or the sender concludes the app is broken when it is obeying the recipient.
The line below still governs preferences among server users:
Drawn there, both of Don's concerns survive. The relaxed tier never builds a correspondence graph that can be subpoenaed — his founding constraint, which does not become cheaper to violate because a user chose convenience. And the strict tier is not a red flag, because it is a performance setting rather than a confidentiality one.
#### Interaction to flag: J7 does not survive frequent polling
Tag scanning at 160KB per poll × 10,000 users × 48 polls/day is roughly 2.3 TB/month of egress. J7 and heartbeat polling are in direct tension: oblivious retrieval or frequent polls, not both, short of bucketing. A further reason J7 stays deferred rather than becoming the plan.
### J12. On-demand internet is a first-class mode, not a degenerate one. — Don's refinement Don: "even 007 needs internet sometimes unless he's always within 30 feet or so of his recipients and senders but he can be choosy about when he exposes himself to it for the purposes of this app."
Correct, and it reframes the strict setting from "never" to "on my terms." Nobody is within radio range of their correspondents indefinitely. J11's strict user is not permanently offline; he is deliberately online, occasionally.
Design consequence: when the door opens, do everything at once. Collect all waiting mail, push all queued outbound, drain, disconnect. One connection, not six scattered across an hour. The user is choosing to be visible for a moment; the design should make that moment short and rare.
Pad the exchange. Fixed-size requests and responses, so an observer of his network cannot infer how much mail he collected or whether he sent anything. Nearly free, and it removes the volume signal outright.
The window is bidirectional by nature — his outbound queue is stuck until he opens it too — and that helps: one connection that both collects and sends is indistinguishable from one that only collects.
The honest limit, which cannot be engineered away. Connecting to our server at all tells his network operator that he uses this app, at that moment, from that location. The rotating token protects whom he is talking to; it does not hide that he reached for us. That is exactly the exposure he is timing, and treating it as something to schedule rather than eliminate is the right model. Reducing it further means onion routing — a large lift for a benefit most users will not value. A VPN is the user's own choice and costs the project nothing.
One counter-intuitive caveat, recorded so it is never over-promised. Rare, deliberate polls are more distinguishable as events than routine ones: constant background traffic is its own cover, while a single poll at 2am is isolated. It leaks no identity — the token is opaque and no IP is logged — but a deposit and its collection can be linked with more confidence when there is little other traffic to hide among. Not a reason to avoid the mode; a reason not to claim it is strictly better on every axis.
### J13. Pipe and cadence are separate axes. Cadence is never a promise. — Don's correction Don: "he can have internet on and polling on demand only instead of with every silent push and depending on workload we can never guarantee exactly how often our desire to push every 30 min or 15 min is actually met."
J12 conflated two axes. Separating them:
| Determines | |
|---|---|
| Pipe allowed | whether senders may deposit for him at all |
| Cadence | when he collects |
Internet on + on-demand polling is a distinct and better mode than internet off. His correspondents are not blocked — their envelopes land and wait — and he still never appears in the push registry. Internet off blocks the sender. The on-demand version is strictly friendlier and gives up nothing.
Cadence can never be a promise. APNs throttles on its own adaptive budget, fan-out to N devices takes real time, and function cold starts add more. Thirty minutes is a target, not a contract. The UI must never print a number the system cannot keep.
Convergence worth exploiting: the manual "check now" action is not a privacy feature, it is the reliability escape hatch for everyone. It belongs in the main interface for all users rather than buried in settings, because it is the honest answer to "why haven't I got my message yet."
Operational consequence — stagger the fan-out, and do it from the start. A synchronised heartbeat is a thundering herd: ten thousand pushes at :00 produce ten thousand simultaneous polls, the worst load shape and the most expensive, since concurrency costs money, not request count. Give each device a stable random offset within its window. Load flattens, cost drops, and individual polls stop correlating with a global tick — a small traffic-analysis benefit for free. Cheap now, awkward to retrofit.
### J14. Mesh relay is reciprocal — carry, or you are not carried. — Don's principle, deferred with mesh Don: *"if we ever go live with mesh and that's still an open question it's like read receipts in whatsapp
The analogy carries a benefit beyond the mechanism: users already understand this shape. WhatsApp trained everyone on "turn it off and you do not get it either." Reciprocity schemes usually fail on comprehension before they fail on design, and this one arrives pre-explained.
What it can be here — and it fits the architecture rather than fighting it. Binary, local and free: the encounter handshake carries a bit saying whether each node relays. If a peer says no, we decline to carry for it. No server, no identity, no reputation system.
What it cannot be. Not "he carried my traffic, so I will carry his." Sealed sender means a relay cannot tell whose envelope it holds, so per-peer accounting is not computable. And opportunistic encounters provide no repeated game — a given node may be met once, ever, which is exactly the condition under which tit-for-tat stops working. Enforcement is therefore at the level of does this node relay at all, and nothing finer.
It is forgeable — a modified client sets the bit and carries nothing. Accept this rather than solve it: the cost of being lied to is one encounter's worth of relay, and hardening it would require the identity infrastructure the product exists to avoid.
The real caution is legal, not technical, and belongs to LEGAL-RISK.md's class of concerns. If relaying is genuinely optional, a user chose to carry strangers' encrypted data. If it is effectively mandatory — opt out and the app stops working — then every paying customer carries other people's traffic whether they considered it or not. "I paid $9.99 and it stores strangers' messages on my phone and drains my battery" is a plausible review and a more plausible complaint.
Survivable if the bargain stays honest: bounded storage, bounded battery, visible in the UI, and a real choice with a clearly stated consequence — the read-receipt bargain rather than a hidden one. It stops being defensible if opting out quietly degrades the app to uselessness while the setting still presents itself as optional.
Status: deferred with mesh itself (J1). A principle to hold, not something to build.
### J15. Three pairing methods, and why the third needs different machinery. — Don's requirement
> Partly superseded by J40: the NFC route is impossible and is not being built. Android removed > peer-to-peer NFC in Android 10 and iOS has never exposed it, so no NFC path exists between two > phones. QR and the shareable code stand; QR turns out to be the only proximity channel available > between any two phones. Nothing below about NFC should be implemented.
Don: "we want NFC pairing and QR code pairing and maybe a 3rd option that could be a code to be read or photographed or texted via another app if desired."
All three must satisfy A7: the server is NEVER in the key-distribution path. That constraint rules out the obvious implementation of the third option — a short code that indexes a key held on the server — no matter how convenient it would be.
| Method | Channel | Carries |
|---|---|---|
| QR | physical, in person | the full public key |
| NFC | physical, tap | the full public key |
| Text code | insecure — SMS, iMessage, any other app | the full public key, in base32 |
QR and NFC are secure because the channel is physical — someone must be standing in front of you. Nothing further is required.
The text code cannot assume its channel is private. SMS and iMessage are readable by the carrier, by Apple, and by anyone holding either phone. Therefore the code must contain nothing secret. An X25519 public key is 52 base32 characters: long to read aloud, trivial to paste into a message. An eavesdropper learns a public key, which is what "public" means.
What the insecure channel still permits is substitution — an attacker who can rewrite the message can swap in their own key. Mitigation: both devices display a short confirmation code derived from the completed exchange, and the two humans compare it out of band (aloud, by phone, in person). This is Signal's safety-number model. It needs no server, adds no new cryptography, and its failure mode is visible: the codes differ.
Alternative considered and not chosen: a PAKE (e.g. SPAKE2). A genuinely short spoken code — six characters — is safe under a PAKE because an attacker gets one online guess and no offline dictionary attack. More elegant for reading aloud, meaningfully more cryptography to implement correctly.
Decision: ship the public-key-in-the-code form with an out-of-band confirmation code. Less code, a failure mode a user can see, and "compare these words with her" is already familiar from Signal and WhatsApp. Revisit the PAKE if a spoken-length code becomes a real requirement.
Consequence for the App ID: NFC pairing requires the NFC Tag Reading capability on com.channelmessenger.app, which must be enabled at registration.
#### J15 addendum — splitting the code across channels. Don's proposal, accepted, with the reason corrected Don: "build the code in such a way that it could be split up and sent in parts through more than one channel, and that way every channel would have to be compromised to lose the code completely. You just don't wanna make it too complicated to be useful."
Correct conclusion, different reason — and the real reason is stronger.
Splitting does not protect secrecy. The code carries a public key; an eavesdropper who intercepts the whole thing learns nothing they could not learn standing next to the recipient's phone. There is nothing to "lose."
Splitting protects integrity, which is the attack that actually matters. The threat is substitution — an attacker who can rewrite the message swaps in their own key and sits in the middle permanently. Against a split code they must control every channel at once and rewrite all parts consistently. Compromise one and the reassembled key fails its checksum and pairing is refused. So Don's sentence is right: every channel must be compromised. To forge, not to read.
Advantage over the out-of-band confirmation code: no synchronous step. The Signal-style compare-these-words check works, but both people must be talking at the same moment. Half by iMessage and half by another app can be sent at midnight to someone asleep and completes when they wake. For Don's stated case — "your kid that lives 500 miles from here… somebody you trust and probably already have several ways of communicating with" — the multiple channels already exist, and this is better ergonomics.
It is nearly free to build, which answers the complexity concern. Not a protocol change: the payload is the same bytes. A share sheet emits "part 1 of 2" and "part 2 of 2"; the receiving side accepts parts in any order and reports when it holds them all. Each part carries its index, the total, and a checksum over the whole. The user never counts, splits or reassembles anything — if they must, it is built wrong.
A good property falls out. An attacker holding one channel can break pairing by corrupting their half, but cannot forge it. Denial rather than impersonation, and visible rather than silent.
Final shape: three delivery methods (QR, NFC, code) and two integrity checks (out-of-band confirmation code, split channels) that compose freely. One channel plus a verbal comparison; or two channels and no call; or both. The split must be generated by the app, never left to the person.
### J16. There is no device migration, and that is currently an accident. — OPEN, needs Don Found while auditing what the app writes to disk, 2 September 2026.
The bug that was fixed. MessageStore wrote every message plaintext and every contact's derived keys as plain JSON to Application Support, with no backup exclusion. iCloud Backup is on by default, so a user's entire conversation history would have uploaded to Apple, and an unencrypted Finder backup would have put the same plaintext on whatever computer the phone was last plugged into. That falsifies the one claim the product rests on — nothing about your conversation leaves the two phones — silently, for every user, by default. The store is now excluded from backup and written with completeFileProtectionUntilFirstUserAuthentication (not full protection, which would make the file unreadable while locked and break background BLE delivery entirely).
The consequence, which is now a deliberate design and must be treated as one. Three things now combine:
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — it never leaves the deviceDevicePreferences (in UserDefaults) is backed upSo a user who replaces their phone loses their identity and every conversation, and must re-pair with every contact from scratch. Their settings survive; nothing else does.
This is correct for privacy and bad for users, and it is not yet a decision — it is a side effect. Signal has exactly this problem and solves it with an explicit device-to-device transfer over the local network, which never touches a server. That is the shape of the right answer here too, and it fits the architecture: the app already knows how to move data directly between two devices.
Needs Don: accept the cliff for v1 and say so plainly in onboarding, or schedule a device-transfer feature before launch. What must not happen is shipping it unstated and letting a paying customer discover it when they upgrade their phone.
### J17. Channel indicators: per-message and per-conversation. — Don's design Don: "a simple small color icon at top of or under each message similar to the whatsapp check marks but where color indicates the send channel and also at top of screen the same colors can show what channels are available at the moment - dim but color visible if not active… this would be chat specific as bt might be good for some chats and not for others more distant."
Two displays sharing one colour vocabulary.
Per message, in the position WhatsApp uses for its check marks: which pathway actually carried it, plus delivery state. This is information no other messenger can show, because no other messenger has more than one pathway.
Per conversation, at the top of the screen: which pathways are available to this contact right now. Lit when usable, dim-but-still-coloured when not — so the colour teaches the vocabulary even when the channel is unavailable, rather than the icon vanishing and leaving nothing to learn from.
Why per-conversation and not global. Don's point: Bluetooth is a property of a pair, not of the device. A contact in the next room is reachable by radio; the same user's contact three states away is not, at the same instant, on the same phone. A global "Bluetooth: on" indicator would be actively misleading. This falls out of J8 — each contact already carries its peer's declared pathways.
Three channels, three colours: direct Bluetooth, direct Wi-Fi, internet mailbox.
What this gives the product that nothing else has. Every other messenger hides its transport because it only has one. Showing the pathway makes the central claim visible rather than asserted: a user watching messages go out over Bluetooth can see for themselves that nothing touched the internet. It converts a privacy promise into an observation.
Honest limit on delivery state. Don: "whether received if we can know that." We can, but only on the direct paths, where the receiving device acknowledges over the same connection. On the mailbox path the sender learns only that the envelope was collected (J6's receipt check) — never that it was read, since a read receipt would have to come back through the blind relay and there is deliberately no identified return path. The interface must not imply certainty it does not have.
Status: designed, not built. Depends on the transport reporting which pathway carried each envelope, which it does not yet surface.
### J18. The wake-up rate IS the check frequency. They were modelled as two questions and are one. — Don's correction Don: "the question is not how often we respond, that answer is the choice above - the answer here is how often the server sends you the wake up for background checks… if someone wants to check in background every 15 min then the server needs to wake every 15 m and someone selecting less often will unload the server so that's a good thing and isn't a matter of respond or not as we'll respond with a pull."
He is right and this corrects J10 and J13. The interface offered two dials — a check cadence and a separate "how do you handle wake-up pings" — as if they were independent. They are not. A device that wants a background check every fifteen minutes needs the server to wake it every fifteen minutes. There is no meaningful third state where a wake arrives and is declined; the device always answers by polling.
One setting, three layers:
| Layer | Behaviour | Configurable? |
|---|---|---|
| Foreground | poll every minute or two | No. The app is open; the user is waiting. |
| Background | the user's chosen frequency, and the server wakes at that rate | Yes — this is the setting |
| Manual | "Check now" | Always available |
Quiet hours. Don: "could have option to not be waked between 10p and 8a." A checkbox, not a schedule editor. Most people do not want to be woken at 3am, and every suppressed wake is battery saved on the device and load never generated on the server.
Cheaper for everyone, which is the elegant part. J11 established that suppressing one heartbeat suppresses every poll it would have caused. Under this model a user who chooses a slower cadence is automatically cheaper to serve — the saving is a direct consequence of their own preference rather than something the operator has to engineer. Interests align instead of competing.
One property of J10 is weakened, and it is worth stating. The blind heartbeat's value was that it was identical for everyone and therefore carried nothing. Per-device rates mean the server learns each device's cadence. But J11 already accepted cadence as the one axis with a server-visible shadow, so this reveals nothing new — and it is still true that a heartbeat never says whether mail is waiting. Keep cadence options coarse, for exactly the reason J11 gives.
### J19. Relay copy: shorter, and named for what it does. — Don Don: "the explanation for relay is too wordy… call it Relay Messages and explain this is for you and for others to extend range of off-grid communication when possible."
Label: "Relay Messages." The J14 bargain still has to be visible — you carry for others, others carry for you — but in a sentence or two, not a paragraph. "Extend range for everyone" is the idea; the reciprocity is the mechanism, and the mechanism does not need three clauses to explain.
### J20. Multiple devices, and messaging yourself. — Don's question, design proposed Don: "can a user send a message to himself?… if a user wants to create the same account on another owned device require NFC or QR for that and if they don't link to their other device at first launch then they get a unique account on that device… think about how that whole flow should work to give flexibility while still secure."
The tension. Identity here is a keypair on a device and there is no server-side account, so "the same person on two devices" has no natural representation. Every mainstream messenger solves this with an account. We deliberately do not have one.
#### Three ways it could work
A — Each device is its own identity; your devices pair with each other like any contact. Fits the architecture exactly and needs no new cryptography. Messaging yourself falls out for free: your phone and tablet are paired contacts, so you can send between them. The cost: a contact who paired with your phone does not know your tablet exists, so their messages reach only the phone.
B — Copy the private identity to the second device by QR or NFC. Both devices then are the same identity, so contacts reach both. Rejected. Two devices sharing one identity both consume the same rotating tokens and the same replay-protection state, so they would fight: a message opened on one becomes a duplicate the other cannot distinguish from an attack. And exporting a private key through a QR code creates exactly the attack surface J15 was written to avoid — the pairing code deliberately carries only a public key, and this would break that invariant.
C — A primary identity that signs per-device subkeys. Signal's model. Correct, and the only option that gives true multi-device. It is also a protocol change to the identity layer, and identity is the hardest thing to retrofit.
#### Proposed: A now, with a concept that makes C unnecessary for most people
Devices are contacts, and some contacts are yours. At first launch a device generates its own identity and takes a name — the user's, or one we generate so two devices are never confusable. Pairing your own two devices uses the same NFC or QR flow as any other pairing, with the same confirmation code, so there is no second security path to get wrong.
What makes it more than a workaround: mark a contact as "my device", and the app mirrors your conversations to it over direct radio whenever the two are in range. A phone and a tablet in the same house are in range most of the time, so they stay in sync without a server, without an account, and without any change to the identity model. Sync becomes another thing that happens opportunistically over the radio — which is what this product already is.
Honest limitation, and it must be said in onboarding rather than discovered. A contact who paired with your phone reaches your phone. Your tablet learns the message by mirroring, not by receiving it directly, so if the two are never near each other the tablet stays behind. Someone who wants both devices reachable independently pairs with both.
Messaging yourself works either way and is worth having on its own: a queued note to self is a genuinely useful thing, and it is the easiest way for one person to test that the app works at all.
Status: proposed, not built. Needs Don's agreement on A-plus-mirroring versus committing to C. If C is ever wanted, decide before v1 ships — retrofitting an identity model onto installed users is the one migration this architecture has no good answer for (see J16).
### J21. Wi-Fi: a pecking order of pathways, ranked by exposure. — Don's design Don: "implement wifi via shared lan (no internet exposure) if a paired device can be exposed if the lan permits it - that's one level. then if direct wifi to the device is available that takes priority over using the shared lan since a sniffer can see shared lan access and direct to device maybe not… several paths to set up with a pecking order in order of security and the app settings can disable or enable each method so they get skipped in the pecking order."
#### The ladder, most private first
| Rank | Pathway | Who can observe it | Speed |
|---|---|---|---|
| 1 | Direct Wi-Fi (Wi-Fi Direct / Wi-Fi Aware) | someone with a radio in range | fast |
| 2 | Bluetooth LE | someone with a radio in range | slow (KB) |
| 3 | Shared LAN | anyone on that network, and the router owner | fast |
| 4 | Internet mailbox | the relay, by design blind | fast |
Why direct outranks LAN, which is Don's point and it is correct. Both are local, but a shared LAN means somebody else's infrastructure. A router logs. A corporate network inspects. On a café or hotel network every other guest is a peer. Direct Wi-Fi touches no infrastructure at all — the observer has to be physically present with a radio, which is a much smaller and more expensive set of adversaries.
Why Bluetooth sits above shared LAN despite being slower. Same reasoning: BLE touches nobody's equipment. Ranking is by exposure, as Don asked; speed breaks ties within a rank, not across them.
Every rung is individually switchable, and a disabled rung is skipped rather than failed on. The ladder is a preference order, not a requirement chain.
#### Answering Don's question directly: can a phone on Wi-Fi still reach a direct Wi-Fi contact?
Generally yes, with a caveat. Modern chipsets support concurrent operation — the radio time-slices between the infrastructure connection and the peer-to-peer link. This is precisely how AirDrop works while an iPhone is on Wi-Fi. On Android, Wi-Fi Direct and Wi-Fi Aware both usually coexist with an active AP connection.
The caveat is real: on some hardware, establishing a peer link degrades or briefly drops the infrastructure connection. It is chipset-dependent and not something we can promise. Believed, not measured — this needs testing on Don's actual devices before any of it is claimed to a user.
#### Platform reality, and where the cross-platform gap actually is
| Shared LAN | Direct Wi-Fi | |
|---|---|---|
| Android ↔ Android | works — standard IP | Wi-Fi Direct since 4.0, Wi-Fi Aware since 8 |
| iPhone ↔ iPhone | works — standard IP | MultipeerConnectivity over AWDL |
| iPhone ↔ Android | works — it is just IP | the hard case — AWDL is proprietary and never talks to Android; Wi-Fi Aware in iOS 26 is the first standards-based route and is unverified |
This is the finding that should drive sequencing. Shared LAN is the only Wi-Fi pathway that already works in every direction, including the cross-platform case, using nothing exotic — mDNS for discovery and ordinary sockets for transport, with NsdManager on Android and NWBrowser/Bonjour on iOS.
Therefore: build shared LAN first. It is the cheapest to implement, the only one that is cross-platform today, and it delivers the largest practical gain — two phones on the same home or office Wi-Fi get fast transfer without the relay and without waiting for a radio encounter. Direct Wi-Fi is platform-fragmented and the interesting half of it is unproven.
iOS requires the Local Network permission (NSLocalNetworkUsageDescription plus declared Bonjour services). It prompts on first use. Worth knowing that a privacy-focused app asking to see the local network needs its explanation written carefully.
#### Hotspot as a last resort
Don: "even if one device has to be a hot spot at times." Technically it closes the cross-platform direct gap, but neither platform lets an app enable a hotspot or join a network programmatically. It can only ever be a guided manual flow — "ask them to turn on their hotspot, then join it" — so it belongs as an explicit user action, never in the automatic pecking order.
Status: designed, not built. Shared LAN first; direct Wi-Fi after; hotspot only if the cross-platform case proves to matter in real use.
### J22. Never call it an account without saying what kind. — Don Don: "the thing about 'accounts' even though accounts are device based and not server based by design so when an account is created the app and docs need to make that distinction clear."
The word "account" carries an assumption that is false here, and it is the assumption most users will bring: that something was created somewhere else, on a company's server, holding their details and recoverable if the phone is lost. None of that is true. Nothing was registered anywhere. No server knows this identity exists.
Preferred term: "identity", qualified on first use as belonging to the device. Where "account" is genuinely the clearer word for a lay reader, it must be immediately followed by what makes it different.
What must be said at the moment one is created, because this is when the user forms their mental model and every later surprise traces back to it:
J16) — this is the direct consequence and users must hear it here, not discover it later.This is a real feature, not a caveat to be buried. "You did not create an account — nothing about you left this phone" is the strongest single sentence the product has. Every other messenger opens by asking for a phone number. Say plainly what did not happen.
Applies to: first-launch copy, the identity area of Settings, both store listings, the privacy policy, and docs/ChannelMessenger-How-It-Works.docx. Also J20 — a second device gets its own identity, and the naming there ("Don's iPhone", "Don's iPad") only makes sense once a user understands these are per-device rather than one account in two places.
### J23. The LAN pathway ships off by default. — Found while building J21 Building shared LAN surfaced an identifier neither platform lets an app suppress.
A DNS-SD registration carries an SRV record, and an SRV record names a host. Both NWListener and NsdManager publish the system's .local hostname there, and no API exposes that field. On iOS that hostname derives from the user-set device name. Suppressing it would mean writing a raw mDNS responder and abandoning both frameworks.
The advertised instance name is clean — a rotating opaque value, pinned by a test that scans the disclosure for every identifier the process can reach and fails naming whichever leaked. That test was deliberately broken to confirm it is not vacuous. The hostname beside it is not ours to control.
The practical effect: joining any network announces "this named device runs Channel" to everyone on it. On a home network that is nothing. On a café, hotel, or office network it links the app — and by extension the fact that you use it — to a named person, on every network you ever join.
So this rung is OFF until asked for, unlike Bluetooth and the mailbox. Everything else in the ladder either touches no infrastructure or is deliberately blind; this one hands a name to strangers.
Note this is a different question from the one Don already settled. He judged that Apple or Google knowing an app is installed is not much of a security problem, and that is reasonable — they sold it to you. This is a stranger on a café network learning it, tied to a device name. Different observer, different bar.
When it is worth turning on, and the setting copy should say so: you and a contact on the same trusted Wi-Fi, where it is dramatically faster than Bluetooth and works iPhone-to-Android, which no other direct pathway does today.
### J24. Two consequences of shared LAN worth recording plainly
Android now requests INTERNET, and a guarantee is gone. There is no local-network-only socket permission below targetSdk 37, and a TCP connection to a phone on the same Wi-Fi needs INTERNET, which does not distinguish LAN from internet. The manifest can no longer prove absence of network access, which it previously could. What remains is weaker but real: the only socket code in the app is the LAN transport, dialling only addresses NsdManager returned for our own service type. The reasoning is recorded in the manifest rather than the old note being quietly deleted. No location permission was added; moving to targetSdk 37 will require ACCESS_LOCAL_NETWORK, and the manifest says so.
Peer authentication does not use presence tokens, and could not. The specification asked for a presence-token match, but their_presence_token is not on the FFI surface — presenceTokens returns our own set, the values a peer uses to recognise us, and the direction byte guarantees the two never coincide. So acceptance instead requires that a sealed envelope from the peer opens under a known contact's keys, which proves possession of the message key rather than the routing secret. That is the stronger property: PROTOCOL.md §4.6 says the routing secret is replayable and is "a hint, never authorisation." Until a peer authenticates it receives no envelopes and does not appear as visible.
---
### J25. The app should behave like a messenger people already know. — Don
Recorded 2 Sep 2026, from Don, while the Play closed-testing setup was in progress. Design intent, not yet built. The through-line: a messenger has to work the way users expect a messenger to work. Everything distinctive about this product is additive to that baseline, never a substitute for it.
#### The baseline, following WhatsApp's model
#### 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
J17; Don restated it here as a requirement, not a nice-to-have — the indicator is permanent per message, not a transient status.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:
store/privacy-policy.md §2 ("Information we do not collect, ever") and §9 (permissions) both state the app cannot read the address book.store/data-safety.md — Contacts: "Not collected. No contacts permission is requested" (citing A2, G1).If contact access ships, both must be updated in the same change, and the Play Data Safety form resubmitted. The honest framing, if built: access is optional, requested only at the moment the user chooses to invite someone, used solely to address that one invitation, and nothing from the address book is stored, uploaded, or retained. Anything less specific than that would make the privacy claim this product rests on into a lie. Not a reason to refuse the feature — a reason to do it precisely.
#### Standing task: download links go stale
Invitations carry links to install the app. Those targets change as status changes — closed beta now, open beta later, public store listings after that. Whoever changes the app's release status owns updating the invite links in the same change. Recorded here so it is not discovered by a recipient clicking a dead link.
---
### J26. "Relay" means a phone. The Netlify service is the "server". — Don
Settled 2 Sep 2026, after the word had drifted across three different things in one conversation. Recorded because the collision is not cosmetic: two of the three senses have opposite privacy stories, and a user reading the settings screen had no way to tell which one the store listing meant.
| Term | What it means | Status |
|---|---|---|
| Relay | Using another phone as a Bluetooth and/or Wi-Fi range extension | Not built (J1) |
| Server | The Netlify service: internet storage for messages not yet pulled by their receiver | Live at channelmessenger.netlify.app, deployed 2 Sep 2026 |
| (unnamed) | A macOS/Windows desktop app acting as a household relay | Undecided, and probably not worth it — see below |
#### Why the distinction matters
The server is something you send through: a blind mailbox that holds a sealed envelope until its recipient pulls it. It is "sort of a relay" in the loose sense, but calling it that invites exactly the confusion this entry exists to end.
A relay is a phone volunteering to carry for others. That is a different act with a different privacy cost, and it is the thing the settings screen's reciprocity copy is about.
Using one word for both makes "your phone can carry for others" and "your message waits on our server" read as the same feature. They are not.
#### Consequences
Server, never Relay. The persisted DeliveryPathway.internetMailbox enum case keeps its name — it is Codable and stored on device; renaming it would break decode of existing conversations for no user-visible gain.server/README.md and the store listing should stop calling it a relay. Not yet done.#### The desktop case, deferred
A macOS/Windows app could act as a household relay, but a house almost certainly has Wi-Fi already, so the relay half may earn nothing. Don's read: the client half is worth doing, the relay half may not be — and neither is critical to basic function. Deferred, not rejected.
---
### J27. The channel indicator system: live lights above, permanent marks below. — Don, colours proposed here
Extends J17 (per-message channel indicator) and J25 (live channel bar). Design intent, not built.
Two related displays that must agree with each other:
The point of pairing them: the lights tell you what is possible now, the marks tell you what happened then, and they use the same colour vocabulary so the connection is obvious without a legend.
#### Colours
| Channel | Colour | Note |
|---|---|---|
| Bluetooth | Blue | Don |
| Wi-Fi | Green | Don |
| Server (internet) | Violet | Proposed here — see the collision below |
Don initially suggested blue for internet as well as Bluetooth. Flagged and changed: those two are the most and least private rungs of J21's ladder, so they are precisely the pair that must never be confused at a glance. Violet was chosen over amber/orange because Don has twice rejected orange tones as clashing with the garnet brand ("that pink/orange clashes with the garnet"), and violet stays cool-toned alongside blue and green while remaining unmistakable from both.
#### Distinguishing within a colour
Two cases need a finer grain than one hue each:
J21 — direct Wi-Fi touches no infrastructure, shared LAN means a router that logs and other guests who are peers. Proposal: filled = direct, hollow/outlined = LAN. Filled reads as "solid, nothing in between"; hollow reads as "something in the middle." That maps the exposure difference onto the shape rather than requiring a second colour.J26 sense — another phone extending range. Not built.)#### The rule this system must not break
Filled/hollow and ringed/unringed encode exposure, never signal strength or speed. If a future change makes a hollow indicator mean "weak signal," the whole vocabulary stops meaning anything and the user learns nothing from it. Same discipline as J21's ranking: exposure, not performance.
#### Measured palette (added after Don asked about contrast on the garnet banner)
The lights sit on the garnet app bar (#6F131B), so these were measured, not chosen by eye. WCAG requires 3:1 for a UI graphic; all three clear 4.5:1 comfortably.
| Channel | Colour | Contrast on garnet |
|---|---|---|
| Bluetooth | #4FC3F7 | 5.87:1 |
| Wi-Fi | #69F0AE | 8.22:1 |
| Server | #FFD54F | 8.34:1 |
Amber replaced the violet proposed above, on evidence rather than taste. Simulating deuteranopia and protanopia, violet collapsed toward both blue (1.19:1 normal vision) and green (1.19:1 protan) — the server indicator became ambiguous against the exact channels it most needs to be distinct from, since server and Bluetooth are opposite ends of J21's exposure ladder. Amber separates cleanly from both under every simulation tested (2.66–3.30 vs Bluetooth, 1.95–2.44 vs Wi-Fi).
This amber is a small indicator dot. It is not the saturated orange square Don rejected for the app icon, and it is not the pink/orange that clashed with the garnet — both of those were large fills; this is a few-pixel light on a dark ground.
#### Colour must never be the only signal
Measuring exposed a defect no colour choice fixes: Bluetooth blue and Wi-Fi green sit at 1.35:1 under deuteranopia — a red-green colour-blind user cannot reliably tell them apart, in any palette tried.
So each channel gets a distinct glyph — a Bluetooth mark, Wi-Fi arcs, a globe for the server — and colour reinforces the glyph rather than carrying the meaning alone. This is WCAG 1.4.1 (Use of Color) regardless, but here it is also a product requirement: J17 and J25 exist so the user can tell at a glance which path carried a message. An indicator that ~8% of men cannot read is not doing that job.
The shape modifiers from the section above (filled vs hollow for direct Wi-Fi vs LAN; a ring for a relayed Bluetooth route) sit on top of the glyphs and inherit the same rule: shape encodes exposure, never signal strength.
#### A letter under each light — the TitleVitals pattern
Don, on seeing the colour-blindness measurement: reuse what already works in TitleVitals — put a letter beneath each light. B Bluetooth, W Wi-Fi, I internet.
This supersedes the glyph proposal above as the primary redundant channel. A letter is smaller, unambiguous, needs no legend, and does not have to be redrawn per state the way a glyph does. Colour becomes pure reinforcement: remove all colour and the display still reads correctly, which is the test that matters.
Note the user-facing letter is I for internet, not S for server. J26 governs what we call the service in code and documentation; a channel indicator names the path from the user's point of view, and to a user that path is "the internet."
#### The lights are per-conversation and continuously changing
Stated plainly because it constrains the implementation: which lights are lit depends on who you are chatting with, and changes minute to minute as people move. These are not device-level capability lights ("this phone has Bluetooth"). They answer a narrower question: can this specific contact be reached by this specific path, right now?
Consequences:
---
### 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
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.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.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:
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:
store/privacy-policy.md §2 ("Information we do not collect, ever") and §9 (permissions) both state the app cannot read the address book.store/data-safety.md — Contacts: "Not collected. No contacts permission is requested."Both must be updated in the same change that ships contact access, not afterwards. Play's Data Safety form asks about data leaving the device; if the address book is only ever read locally and nothing derived from it is transmitted, "not collected" may remain correct there — but the privacy policy's flat statement becomes false either way, and that is the document the product's claims rest on. See J25, which recorded this hazard when the feature was first raised.
The honest framing, if built: access is requested only when the user chooses to import or invite, used solely for that, and nothing from the address book is stored, uploaded, or retained.
---
### J31. Settings are self-evident; attachments are gated by physics. — Don
Two related instructions, 2 Sep 2026, from reading the shipped settings screen.
#### Settings need no paragraphs — this amends J29
> "if you look at apple settings i don't think you see much of any explanation on any of the items. > the settings should be obvious and not need a paragraph."
J29 said carry more per word. This goes further: the right number of words is usually zero. The cadence section's explanatory block was deleted outright rather than shortened, because it "says nothing useful and potentially misleading" — it opened with "One schedule, not two", a comparison meaningful only to someone who knew there had once been two, and it claimed "our server wakes your device", which is false (heartbeat.mts is a placeholder).
The rule: if a setting needs a paragraph to be honest, the label is wrong. Fix the label. A control called "About every 15 minutes" needs no footer.
This does not license vagueness. J29's requirement that a trade-off state its cost still stands — but it must fit in the label or a single short line, and where it genuinely cannot, that is a signal the control is badly named, not that a paragraph is owed.
Why it matters beyond tidiness: Don's reason is density. "there will be more items added of course to the settings. i want most of the options available in whatsapp as a standard." A screen that spends a paragraph per control cannot hold a messenger's worth of settings.
#### Attachments, and the bandwidth that decides them
Images and attachments are wanted "at some point". The constraint is measured against what the protocol already fixes:
policy::MAX_ENVELOPE_BYTES = 32_768, policy::MAX_FRAGMENTS = 16. A message today is capped at 32 KB. Text is unaffected; any image exceeds it immediately. Attachments therefore require a protocol change, not just a UI.PROTOCOL.md §6.2: an encounter window "may be ten seconds in a corridor." A 2 MB photo needs 35 s – 2 min of sustained Bluetooth. Photos over Bluetooth are realistic between two phones sitting together, not between people passing.So the policy Don proposed is the correct one and is forced rather than chosen: above a small size threshold, an attachment requires Wi-Fi or an unmetered connection, with a metered-cellular setting because "some pay for data on cellular metered and some are unlimited".
Open, and worth closing cheaply: real iOS↔Android BLE throughput varies enormously by chipset and we have two phones. Measure it rather than shipping a threshold derived from my general figures.
---
### J32. Contact detail, calling, and backups we cannot read. — Don
Stated 2 Sep 2026, extending J25 and J30. Design intent, not built.
#### Three screens
J30, which still applies and still must land in the same change.#### 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:
PROTOCOL.md — plus a published description of the plaintext format inside it.It also means the key cannot be one only we can regenerate. A user-held passphrase, or a printable recovery key, with the derivation specified.
#### Why this one is philosophically consistent rather than an exception
Every other decision here removes us from the path — no accounts (J22), sealed sender, a server that cannot read (J26), no analytics. A backup we could decrypt would quietly undo all of it: it would be the one copy of everything, held where we could be compelled to produce it. Backups that we cannot read, stored where we do not control, is the only version of this feature consistent with the rest of the product.
---
### J33. Compromise isolation, and the WhatsApp settings we should match. — Don
Two questions from 2 Sep 2026, taken together because the answer to the second is in the first.
#### "If someone's phone is compromised, are their contacts compromised too?"
No — and that is structural, not luck. PROTOCOL.md §6.5's Handshake v1 derives a separate sharedSecret per pair, from a fresh ephemeral X25519 exchange. There is no group key, no shared secret spanning three parties, nothing an attacker can lift from Bob that helps against Alice↔Carol.
What compromising Bob's device does give an attacker, stated plainly:
| Exposed | Why |
|---|---|
| Bob's entire message history | Stored plaintext on his device; no forward secrecy over stored messages |
| The ability to impersonate Bob going forward | They hold his identity key |
| Bob's contact list | Which is itself a social graph, and is exactly what sealed sender protects on the wire but not at rest |
Not exposed: any conversation Bob was not part of.
#### The gap is detection, and we already have the mechanism
The attacker who holds Bob's keys can talk to Alice as Bob, and Alice has no way to notice.
WhatsApp solves this with "Get notified when your security code changes for a contact" (Don's own screenshot of Security notifications). We have the primitive already: §6.5's generation counter increments on every re-pair, and PairingCode.confirmationCode is the short phrase two people compare. What is missing is only the surfacing:
#### WhatsApp settings worth matching
From Don's screenshots, and his framing: "I want all the useful things — of course we are not creating accounts in the traditional sense, or rather the accounts we create are just stored on the user's own phone, and perhaps in the contact list of people with whom he is paired."
That last clause is the design in one line: identity lives on the device and in the contact lists of people you paired with. Nowhere else. (J22.)
Worth having: Privacy (last seen, profile picture, about — each scoped to paired contacts rather than "everyone", since we have no "everyone"), Security notifications (above), Chats (backup — see J32, wallpaper, history), Appearance, Notifications (J25), Storage and data, Starred messages, Lists, Disappearing messages, Linked devices (J20), Blocked contacts, Invite a friend (J25).
Not applicable, and worth being explicit about so nobody adds them by reflex: Account Center, Communities, Broadcast, Status/Updates, Parental controls, anything tying to a phone number.
---
### J34. The Handshake v1 external review, and what verification changed. — 2 Sep 2026
Ran Grok against handshake.rs before shipping, per CLAUDE.md's standing rule for anything security-critical. Cost under three cents. Every claim was verified against the code before any of it was acted on, which is the part that mattered: of five findings, only two survived at the severity claimed.
| Finding | Claimed | Verified as |
|---|---|---|
| Completed handshake destroyed by any later packet | High | CONFIRMED. on_packet clears output on any error; step's _ arm errors for every packet once Complete. A replay or a duplicate BLE delivery silently discards a successful pairing. |
| Ephemeral secret not zeroized on error paths | Medium | CONFIRMED. There is no impl Drop for Handshake. Live X25519 private material survives every pre-finish failure. |
generation attacker-inflatable | Medium, framed as contradicting the spec | Real, but mis-framed. It does not contradict §6.5 — the spec says exactly this is possible and accepts it. The genuine defect it missed: no upper bound, so inflation to u32::MAX makes re-pairing that contact permanently impossible. |
Forged PairOffer consumes the state machine | High | Real, but low. A nuisance DoS on a face-to-face ceremony where both parties retry. The first flight is unauthenticated by design. |
| Transcript bounds guard skips silently | Low | Real hygiene. Not triggerable today; becomes a silent, baffling signature failure the moment a field width changes. |
#### Why this is worth recording rather than just fixing
The review's value was not its severity ratings — it got two of five wrong in both directions, overstating one and understating another. Its value was being a different reader: every real finding is the same shape CLAUDE.md already predicts, an outer construct enforcing a rule an inner one does not. on_packet treats "error" as one category and clears state uniformly, without distinguishing "failed before completing" from "already succeeded, this packet is spurious."
The discipline is the product, not the tool. Relaying these unverified would have shipped one redesign nobody needed (the DoS), missed the unbounded-generation defect entirely, and left the two real bugs correctly described but buried among three that were not.
---
### J35. "No channel" is a fourth state, not a missing one. — Don
From testing the first working conversation on a real Android phone, 2 Sep 2026.
A message sent to yourself never leaves the device. No radio carried it, no server saw it, and J27's three channels — Bluetooth blue, Wi-Fi green, internet amber — all describe paths that were not used. Don's read: "for a message sent to yourself, I guess white is as good as any."
He is right, and it generalises: "nothing carried this, it never left the phone" is a real state that deserves its own mark, not a borrowed one and not an absence.
#### Why an empty bar is worse than no bar
Self-conversations are local-only by construction — never sealed, never transported, excluded from every transport's contact list. So ChannelReachability correctly reports nothing, and the live indicator bar renders empty. That is right, and it looks broken.
An indicator that shows nothing on the first conversation a user opens teaches them the indicators do not work. They then discount the feature everywhere else, including where it is carrying real information about whether their message crossed a server. The cost of a correct-but-blank display is the credibility of the whole mechanism, which J17 and J25 describe as the one thing on screen no competitor can show.
#### The rule
J27's constraint still binds: colour is never the only carrier. Whatever mark is chosen must read correctly in greyscale, like B/W/I do.#### The near miss worth recording
This was found in the same photograph as a top-inset clipping bug on the same screen. An occluded bar and an absent bar look identical from a phone in a hand — so "the lights don't show" had two plausible causes with entirely different fixes. Establish which before changing anything; that is why the instruction to the implementer was to determine the cause first rather than to add a bar.
---
### J36. Check marks carry the path in colour; the bar is the key. — Don
Refines J17 and J27's per-message mark, 2 Sep 2026, after seeing the first working conversation.
> "use the check marks like whatsapp for sent and read but the color should reflect the path it took > to the other user so no letters there as it's too bulky but they can refer to the color at top of > screen if they want a key"
J25's familiarity test applies: this is a shape people already read without being taught.J27's measured palette, blue Bluetooth, green Wi-Fi, amber internet, and J35's fourth state for a message that never left the phone.B/W/I stay; a reader who does not know what amber means looks up once and then knows.#### This relaxes J27, deliberately, and here is the cost
J27 says colour must never be the only carrier, because Bluetooth blue and Wi-Fi green sit at 1.35:1 under simulated deuteranopia. On the check mark, colour now IS the only carrier. A colour-blind reader cannot tell from the mark alone which path a given message took.
That is an accepted trade, not an oversight, and it rests on one thing: the key is always on screen. Legend-plus-colour is a legitimate pattern only while the legend is actually visible.
Therefore this decision depends on J35's companion fix — the bar staying pinned regardless of the keyboard. On 2 Sep the bar vanished whenever the IME appeared, which is most of the time a messenger is in use. If that regresses, this decision silently becomes "colour only, no key," which J27 forbids. Whoever changes the bar's visibility owns re-reading this entry.
A cheaper hedge worth considering rather than reintroducing letters: make the per-message mark's path legible on tap or long-press, so the information is recoverable without spending pixels on every row.
---
### J37. Four letters, no dots: B W L I, grey when a path cannot carry. — Don
> "Ok, for the lights rather than dots let's do letters in colour. An inactive path is grey and an > active path is in colour, and we will say B for bluetooth, W for direct wifi, L for LAN, and I for > internet. They could be different colours or the same, but if the pipe is not available it's going > to be grey. This way you can in a very compact way show what is available for the message you are > about to send."
The dot is gone. Every previous version drew a dot with a letter under it — two rows of vertical space to say one thing twice, and the half that was redundant was the half that could actually be read. ChannelLightView now draws a letter and nothing else.
#### LAN gets its own letter, which retires J27's hollow/filled rule
J27 made direct Wi-Fi and shared LAN both W, told apart by a hollow versus filled dot, on the reasoning that they are one channel to a user and differ only in exposure.
That had the user model backwards. "Straight to that phone" and "across the café's router" are not one channel wearing two hats — they are the two things a person most needs told apart, because the second means a router that logs and other guests who are peers (J21 rung 3). Encoding it as the fill of a 10-point dot put the most privacy-relevant distinction in the display into its least legible feature. isHollow is deleted, not merely unused: a spare boolean named after a shape nothing draws is what the next person gives a second meaning to.
A side effect worth naming, because it is a bug fix hiding inside a redesign: a contact reachable both directly and over a shared LAN used to light one W, with the more private pathway winning. The fact that a shared network was also open simply was not on screen. Now both letters light.
#### Available versus not: colour AND weight, never colour alone
Lit letters are their channel colour and heavy; unlit letters are neutral grey #ADADAD (5.24:1 on garnet, measured) and regular weight. Two signals, one of which is not colour — the same discipline the letter was introduced under, and it matters more now that the letter is the entire indicator.
The grey is achromatic on purpose (there is a test). Beside three saturated letters, an unsaturated one reads as "off" instantly and keeps reading that way under every colour-blindness simulation, because chroma survives where hue does not. It is bright enough to stay legible because J17 requires the vocabulary to be learnable from the dark state too — an unavailable channel is still information.
#### W and L share the Wi-Fi green
Don allowed either ("they could be different colours or the same"). Same, because it is honest — it is the same radio and the same medium, and what differs is who else is in the path, which is now the letter's job — and because a fourth hue would have to be measured against garnet and re-simulated against the other three under deuteranopia and protanopia. That exercise already forced violet out of this palette once. Reusing green introduces no unmeasured colour.
#### Order is the ladder, not the letters as spoken
The bar reads W B L I — J21's order, most private first — not the B W L I of Don's sentence, which was naming the letters rather than ordering them. The invariant that pays for this: the leftmost lit letter is always the route a message would actually take, so the bar and PathwayTransport agree without the user being taught a rule. There is a test.
#### What this does not touch
J36's per-message check mark. That entry says the mark carries the path in colour with no letters, and it is still unimplemented — MessageChannelMark currently draws a letter on a garnet chip. This change neither implements nor blocks it. Note that J36 explicitly depends on the bar being a visible key, so whoever implements it should read this entry first: the key now has four letters, not three.
Verified, not assumed: 90 tests pass on an iPhone 17 Pro simulator, including new ones pinning the one-to-one pathway map, the shared W/L colour, the achromatic grey, and its contrast on garnet.
---
### J38. Adding someone from the address book, with no permission and no copy. — Don
> "And yes, we'll ask permission to load the user's contacts, but we will not save them anywhere > other than on the user's own device ... and really, do we have to store a copy of the contacts if > they're on the device already? Can we display a picker and show contact detail without double > storing them? If so, that's better."
Yes, and better than the question assumed: no permission is needed at all.
CNContactPickerViewController runs out of process. From Apple's own documentation:
> "The app using contact picker view does not need access to the user's contacts and the user will > not be prompted for 'grant permission' access. The app has access only to the user's final > selection."
So the user opens their own address book, in the system's interface, and hands over one card. There is no permission prompt, no CNContactStore, no NSContactsUsageDescription — do not add one, its presence is what makes iOS prompt — and no copy of the address book anywhere.
#### The bulk-read method was deleted, not left unimplemented
PhoneContactSource used to declare func importableNames() async -> [String]. That signature is the whole address book in a return value, and satisfying it honestly required authorization, a store enumeration, and a usage description. It is gone. An unused method returning every name on the phone is an invitation, and J30's promise — "a name the user could have typed is the entire payload" — is worth more as a type than as a comment. What survives is a capability flag.
The picker hands back CNContact; we read the name via CNContactFormatter and nothing else. The closure's parameter is a String, not a CNContact, so a future feature that wants a phone number has to widen that signature in the open rather than quietly reading another key.
Multi-select is deliberately off. It would rebuild the bulk import this design exists to avoid, one screen further along.
#### The two shipped documents changed in this same commit — as J25 and J30 require
That rule was the whole reason this feature sat unbuilt, and it was nearly missed here even so, because the permission claim survived intact. It is the capability claim that did not: privacy-policy.md said "The app cannot read your contacts", which stops being strictly true the moment a picked name arrives. Both documents now state the picker's behaviour precisely.
Play's Data Safety answer stays Not collected — nothing derived from the address book leaves the device, and "collected" in Play's form means transmitted off-device. That reasoning is now written into store/data-safety.md rather than left for the next person to re-derive under time pressure.
#### What is still open
Showing live contact detail — photo, phone number, kept in step with the address book — is not what this delivers, and it cannot be done at zero permission. Re-reading a contact later requires authorization (full or iOS's limited access), and the picker's no-prompt guarantee holds precisely because the app cannot read anything it was not handed. If Don wants live detail, the honest trade is to store the contact identifier only and re-read through CNContactStore — no duplicated data, but a real permission prompt and another privacy-policy revision. That is his call, not an implementation detail.
---
### J39. Six languages, device default, OS translation only. — Don
Stated 2 Sep 2026.
#### Localisation
#### 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:
| App | Model |
|---|---|
| Threema | One-time ~€5. Swiss, no accounts, no phone number. The closest analogue to this product, and a real business. |
| Telegram | ~$5/mo Premium, plus ads in public channels |
| Signal | Nonprofit; donations, seeded by a $50M loan from Brian Acton |
| Wire, Wickr | Pivoted to enterprise/government; Wickr acquired by AWS |
| Charged $0.99/yr pre-Meta, then dropped it |
The pattern: privacy-first messengers monetise by charging up front, selling to enterprise, or being a nonprofit. Advertising and data monetisation are structurally unavailable here — they are the thing this product is sold against.
On build-then-sell, recorded because Don raised it and it deserves a straight answer rather than enthusiasm: it is a real exit, and it has a specific problem. An acquirer pays for the user base because it wants the data the product promised not to collect. Users would be buying a promise whose breach is the exit strategy. Threema's route — charge a few euros, stay independent — is the one that does not require that.
Still open: what to charge, and whether the 14-day-trial-then-subscription Don floated earlier fits a product with no server cost per user. Nothing here settles J4.
---
### J40. Pairing is a rendezvous, and the number is discarded. — Don
Don, setting the constraint: "we need to lower, and not increase the friction inherent in pairing… at the end of the process we want any contact info to only land on our device and that of the contacted person and not to live on the server." And, decisively: "even if we allow numbers to link people we still don't have to store it if we use the initial connection to exchange something more private that is used for day to day comm via the server."
That is the whole design. A phone number answers exactly one question — where do I leave this so the right person finds it? — and is then dead weight. So it exists on the server for minutes, in a form the server cannot read, and never again.
The rendezvous slot. Alice enters Bob's number; her device generates a six-digit code. slot = HKDF(bob_number ‖ code). The code never reaches our server, so the slot address is not derivable from the number space alone. She deposits her identity key and an ephemeral key there, encrypted under the same derived key; the server sees an opaque address holding an opaque blob. She sends Bob the code by any channel. His device knows his own number, derives the same slot, decrypts, and deposits his reply in the same slot. Both devices collect and delete. TTL ten minutes.
Why this matters beyond remote pairing: the slot is the return path that does not otherwise exist. Pairing today requires both people to scan because the second device has nowhere to put its answer — the deposit address for a reply derives from contact keys it does not yet hold. Give it a slot and one scan completes the ceremony. The same mechanism serves QR, NFC, a link, and a number, and later carries device linking (J41).
The hard constraint, which is arithmetic and not an implementation gap: a first message cannot be encrypted to someone whose key you do not have. Therefore the bootstrap mailbox carries key material only — never content. The sender's message stays queued on their own device and goes out over the normal encrypted path once the reply arrives. To the user it reads as Delivering, then delivered.
This is not fastidiousness. The phone number space is roughly 10¹⁰, so anyone — including our own server — can enumerate every number, compute every bootstrap address, and poll them all. We cannot prevent enumeration. We make it worthless by guaranteeing there is never anything at those addresses but a public key and a claim. Every hashed-contact-discovery scheme that skipped this step was broken by exactly this attack.
Write control. An address anyone can compute is an address anyone can deposit into. Unpaired inbound therefore surfaces the way an unknown SMS does — "+1‑555‑1234 wants to message you", accept or decline — with rate limiting per address. The spam decision belongs to the person.
The setting this creates: "Let people who have my number reach me." Off, and the device polls no bootstrap address at all; you are reachable only by QR, NFC, or link. On, and you get WhatsApp-grade convenience. Don's framing: "freedom with options is what i sell and not selling the client."
Once the handshake lands, the pair never touches the number-derived address again and the number itself is discarded rather than stored.
#### A QR scan does not prove presence; the compared code does
Don, pressing the NFC question again: "what do QR codes get us that NFC cannot... this would only be used to guarantee that a user was face-to-face with the person he's communicating with, raising that channel to that person to the highest level of authentication."
Direct answer: nothing. If peer-to-peer NFC existed cross-platform it would be better — a tap enforces roughly 4 cm by physics, needs no camera, works in the dark, and has no aiming. QR's entire advantage is availability: every phone, both platforms, no OS gatekeeping. It wins by walkover, not on merit. (NFC's unavailability is recorded above.)
But the premise needs correcting, and it changes what the badge may claim. A QR scan does not prove face-to-face. The app knows only that a camera saw a code. It cannot distinguish scanning a person's screen from scanning a screenshot they texted you — the bytes are identical, and a forwarded image reaches exactly the same state.
What proves presence is the compared confirmation code. Two people reading the short phrase aloud to each other requires a live human channel that a relayed image does not provide. NFC would have supplied proximity for free; with QR it has to come from the humans.
| Tier | What it actually proves |
|---|---|
| QR + confirmation code compared aloud | They were together. The comparison does the work, not the scan. |
| QR alone | A camera saw a code. Possibly a forwarded photo. |
| Link or code via another channel | As strong as that channel; whoever controls it can substitute |
| Number rendezvous | Control of that number at that moment |
Therefore the badge must say "arrived via camera", not "verified in person", unless the app actually required and recorded the code comparison. A badge that infers presence from a scan flatters the user and is wrong in exactly the case an attacker would construct. Whether comparison is required before a contact reaches the top tier is an open UI question; what is settled is that the badge may not claim more than the app observed.
On spoofing the number tier: the recipient derives the rendezvous slot from their own number, so an attacker must genuinely control that number, not merely spoof caller ID — which is trivial and would otherwise be the obvious attack. The real exposures are SIM swap and VoIP numbers never tied to a person. Good, not great, and correctly ranked below the in-person tiers.
#### Tone: describe, do not warn
Don: "we do want to be transparent without making people think we are less secure than the competition, which, if you mention it too much, it makes it look that way. So you have to be careful of the wording of that, or just explain it more fully in the documentation and leave it at that."
The comparison is better than "no worse than." Every mainstream messenger routes all key exchange through its own servers — WhatsApp, Signal, all of them — and none offers an in-person alternative. So rendezvous pairing is exactly as good as their only path, and the QR path is better than anything they offer. Warning heavily about the option that matches the industry norm, while competitors say nothing at all, would leave users believing the more careful product is the riskier one. That is a real failure mode of transparency and it has to be designed against.
A second reason to keep it light: a warning on the normal path trains people to ignore warnings. If most pairings are remote and each one carries a caution, the caution stops being read — and then the genuinely weaker cases have nothing left to signal with.
Therefore:
| Where | What |
|---|---|
| The badge | Does the real work. Comparative, not alarming — "in person is stronger", never "this is unsafe". Persists on the contact instead of flashing once. |
| At the moment of choosing | One neutral sentence. Describe, do not warn: the key passes through our server briefly, we cannot read it, pairing in person avoids it. No warning icon, no red, no the word "risk". |
| The documentation | The full explanation, including the comparison above — where it reads as context rather than defensiveness. |
This satisfies J29 rather than contradicting it. J29 requires that a benefit never be stated without its cost; it does not require alarm. A cost stated once, neutrally, and carried thereafter by a persistent comparative badge is more honest than a warning users learn to dismiss.
#### Assurance is asymmetric, and both ends see it
Don: "a badge on the contact can show level of certainty as to identity at both ends of the chat."
The badge is a property of how the key arrived, and it differs per direction. If you scanned Lusmar's QR in person but she only tapped a link you texted, you hold strong evidence about her and she holds weak evidence about you. A single badge for the pair would be false in one direction. So a contact record carries two values — how I obtained their key, and what I can infer about how they obtained mine — and shows the weaker. Badge only, no paragraph, per J29.
| Method | What it proves | Where it is weak |
|---|---|---|
| QR in person | The camera saw the real screen | Nothing meaningful |
| Link you send | Only as strong as the channel it travelled | Whoever controls that channel can substitute |
| Number rendezvous | Whoever controls that number | Our server mediated; number-holder ≠ person |
NFC is not among them, and cannot be. Don asked the right question — "if close enough to use QR why not just do nfc?" — and the answer is that phone-to-phone NFC does not exist on current platforms. Android's peer-to-peer NFC (Android Beam) was removed in Android 10 in 2019, and iOS has never exposed phone-to-phone NFC data exchange; Core NFC reads and writes tags, and the Secure Element work is card emulation for payment, not an app data channel. iPhone↔Android NFC is therefore impossible and Android↔Android has not worked for years. An earlier draft of this table listed NFC as a working method; that was wrong, and any store copy implying NFC pairing must be corrected with it.
This is what QR actually buys us: it is the only proximity channel available between any two phones, and it is the strongest one — you are looking at the other person's real screen, so nothing can be substituted in transit. Needing two scans was never inherent to QR; it was the missing return path, which the rendezvous slot supplies. QR is also the only sensible way to link a laptop or desktop (J41), which has a screen and no NFC — the same reason WhatsApp uses it there.
One unbuilt possibility, recorded so it is not rediscovered: an Android device using host card emulation could present a tag an iPhone reads via Core NFC, giving a one-directional Android→iPhone transfer, which a rendezvous slot could complete. Not worth the platform-specific machinery when QR already does it better in both directions.
Assurance upgrades, and a failed upgrade is the detection story J33 lacks. Two people who paired by link and later meet can scan once to promote to verified, comparing against a key already held. If the scanned key does not match the stored one, someone substituted it. J33 records that compromise isolation is structural but undetectable; this makes it visible, using a gesture people already perform for another reason.
---
### J41. One identity across devices; several accounts on one device. — Don
Don: "if i used the phone in the day could i continue the chat on the macos app or my ipad? …i'd like to break that 2 limit thing all in a single app."
Two problems sharing one primitive — the ceremony in J40, pointed at yourself.
One identity, several devices. Each device holds its own keypair; the account identity key signs a device certificate for each. Contacts store the account key plus the set of authorised devices, and senders encrypt to all of them. No private key ever leaves a device, which is what keeps this honest. Linking is show-QR-on-the-Mac, scan-with-the-phone; history transfers over LAN, not our server — BLE cannot carry a backlog at 20–60 kB/s (J31). Revocation is a signed statement that propagates on next contact, which also gives J33 a second detection path.
Several accounts on one device is then a container problem, not a crypto one: namespace the keystore, the message store, and the token set by account id. WhatsApp's two-app ceiling is commercial, not technical — they want businesses on WA Business. We have no such incentive, and breaking it is a genuine differentiator.
Correction, recorded because it changes the design. An earlier draft of this entry claimed notifications must carry the account. Don: "our notifications aren't by user, they're by device. And the notifications are really just wake up calls." That is right, and it makes multi-account simpler: the wake-up is content-free, so one device has one wake-up stream regardless of how many accounts it holds. Attribution happens after decrypt, on-device, from material only the device has. It also means the number of accounts on a device never leaves it — a per-account notification channel would have leaked exactly that. The only remaining constraint is that all accounts' tokens go in one poll: N accounts cost one wake-up and one round trip, not N.
---
### J42. Sent, delivered, read — and why the tick lags. — Don
Don: "whatsapp shows when sent and when delivered and when read for each message and we should too if able."
This composes with J36: the count carries progress, the colour carries the path. Two dimensions on one glyph, no letters.
J43.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:
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:
J29 requires it be short; it must also be the real cost rather than a softened one.subscribe.mts's row expiry is a privacy control, not housekeeping. It bounds how much a prospective order sweeps up, which is the exposure that actually matters (see below). It should stay as short as the battery saving tolerates.#### Prospective orders are the real exposure, and E2E is what blunts them
Nothing can produce what was deleted, but a provider can be ordered to stop deleting. The structural answer is that compelled retention still yields ciphertext, because keys never reach this server (A7). "Preserve everything from now on" does not become "read everything."
The genuine frontier is compelled code change -- not "hand over data" but "ship a build that weakens the client." End-to-end encryption has no technical answer to that today. The partial answers are reproducible builds and binary transparency, so that a targeted malicious build is detectable rather than merely forbidden. Not built, not scheduled, and recorded here because it is the only defence in this entry that does not depend on winning an argument in court.
Don's summary of the position, which is accurate: "the exposure only produces meta data if the court doesn't force you to ship spyware to users." Metadata is the ceiling, and the client staying honest is the condition.
The platforms are not equal here, and the difference is structural.
| Can a user verify their binary? | |
|---|---|
| Android | Yes. Build from source, sideload, compare. A targeted malicious update is detectable by anyone who bothers -- and the possibility of detection is the deterrent, since a targeted attack that gets discovered is worse than useless to whoever ordered it. |
| iOS | No. Apple re-signs binaries and the store is the only channel, so nothing lets a user confirm that what they received is what we uploaded. No build discipline on our side fixes this. |
What follows now, while it is still cheap: deterministic builds -- pinned toolchains, no embedded timestamps, sorted inputs -- and keeping the client source publishable. Both are nearly free today and both become expensive to retrofit once build scripts assume otherwise. Neither commits us to making the claim; they are what make the claim possible later.
#### Why the app must not checksum itself, recorded because it gets re-proposed
Don: "you could crc check big chunks of your pertinent code and publish a value in the app store description that needs to match what the app itself calculates -- but the court could order you to lie about that too." The last clause is right, and there are two further reasons it fails:
The 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):
---
### J47. Relay-assisted discovery, and the flood it invites. — Don
Don, 3 Sep 2026: "add the phone as relay code too... so this includes the discovery parts both for the destination and the relay to be able to assist with it so if you don't see the destination directly you see if any relays are available and ask them to look for it too" — and "relays might even look for other relays if available to do the same search."
This is multi-hop discovery, which is a different problem from the multi-hop carrying J1 and J14 defer. Carrying moves an envelope you already know where to send. Discovery is asking "can anyone see this person?", and the asking is the part with the privacy and cost problems.
#### The property that makes it viable at all
A naive version — "have you seen Bob?" — hands every relay a social graph. Ours does not have to, because PROTOCOL.md §4.4 already gives every contact a rotating presence token: an opaque 16-byte value derived from the pairwise routing secret and the current epoch, meaningless to anyone who does not hold that secret, and unlinkable across epochs.
So the query is "do you see this token?" A relay checks it against the tokens it has heard in beacons and answers yes or no. It learns that somebody is looking for an unnamed correspondent, which is strictly less than it learns by carrying an envelope for them — a thing J14 already contemplates.
What it does add, stated plainly: THREAT-WALKTHROUGH.md already concedes a relay can link a query to a following envelope by timing. Multi-hop discovery multiplies the number of relays holding that timing observation, and the second hop learns the query without ever seeing the requester. That is a real widening of the metadata surface, and the honest framing is that relay mode trades metadata exposure for reach — the same shape as J44's push trade, and it should be presented the same way.
#### The flood, which is the actual engineering problem
"Relays might even look for other relays" is, unbounded, a broadcast storm: every node asks every neighbour, who asks every neighbour, forever, in a topology with cycles. Three bounds are required and none is optional:
| Bound | Why |
|---|---|
| Hop limit | A query carries a small TTL, decremented per hop, dropped at zero. J21's ladder already implies two hops is the useful range; beyond that latency exceeds what a person will wait. |
| Duplicate suppression | A query id, remembered briefly, so a cycle does not carry the same query round forever. The same mechanism PROTOCOL.md uses for envelope duplicate suppression. |
| Rate limit per neighbour | Otherwise one hostile node turns every relay into an amplifier against the rest of the mesh. |
The hop limit cannot be authenticated, exactly as PROTOCOL.md already records for envelopes: relays share no key with the requester, so a malicious relay can reset the TTL. That makes rate limiting the real defence and the hop limit merely the cooperative case.
#### Reciprocity, inherited
J14 governs: relaying is binary, local and free — the encounter handshake carries a bit, and a node that does not relay is not relayed for. Discovery inherits it unchanged. It cannot become per-peer accounting, for the reason J14 gives: sealed sender means a relay cannot tell whose envelope it holds, and opportunistic encounters give no repeated game.
#### Cost, which decides whether anyone leaves it on
Answering queries means listening more, and listening is the expensive radio state. A relay that answers everything is a phone with a flat battery by lunchtime, and a feature people switch off is worth nothing. The battery budget belongs in the design before the protocol work, not after — it is the constraint most likely to kill the feature, and the one easiest to discover too late.
#### Status
Design only. Not built, and deliberately not started tonight: it needs new packet types, changes in mesh-core-rs, and both platforms, and half of that landing unsupervised is worse than none of it. It also sits behind the Gate in ROADMAP.md — a relay mesh built on a pairing whose secret is derived from public keys would be a larger version of the same mistake.
---
### J48. Direct Wi-Fi cannot cross platforms; on iOS it is nearly free. — Don
Don, 3 Sep 2026: "WiFi to WiFi direct isn't present in app." Correct, and worse than unwired: DeliveryPathway.directWiFi is rung 0, the most-preferred path on the whole ladder, and no transport for it exists on either platform. The W light in the channel bar has never been able to light. Same shape as the Bluetooth defect (J-BLE, fixed 3 Sep): a pathway named in the enum, shown in the UI, implemented nowhere.
#### The hard constraint: it cannot work between an iPhone and an Android phone
| Peer-to-peer Wi-Fi API | Interoperates with the other? | |
|---|---|---|
| iOS | No Wi-Fi Direct API at all. Apple's peer-to-peer Wi-Fi is AWDL, reached through MultipeerConnectivity or NWParameters.includePeerToPeer. | No — AWDL is Apple-proprietary |
| Android | WifiP2pManager, i.e. Wi-Fi Direct proper | No |
So Wi-Fi Direct the protocol can only ever work same-platform. This is the NFC finding again (J40) — a capability that reads as universal and is not.
But "direct Wi-Fi between an iPhone and an Android" is NOT impossible, and an earlier draft of this entry said it was. Don: "how does wifi connect to a printer or washing machine work during set up... many devices allow direct wifi by manifesting a temporary hotspot." Exactly — appliance setup almost never uses Wi-Fi Direct. It uses SoftAP provisioning: the device raises its own temporary access point, the phone joins it as an ordinary Wi-Fi client, they talk over that network, and the hotspot goes away. No peer-to-peer protocol anywhere.
That pattern crosses platforms, asymmetrically:
| Role | Android | iOS |
|---|---|---|
| Raise a hotspot | Yes — WifiManager.startLocalOnlyHotspot, API 26+ | No. Personal Hotspot is user-initiated with no API |
| Join one | Yes | Yes — NEHotspotConfiguration, with a prompt |
So Android hosts and the iPhone joins, never the reverse, and the SSID and password could ride in the pairing code already on screen.
The costs make it a deliberate mode, not a background path. Joining a hotspot drops the iPhone off its normal Wi-Fi, so it loses internet for the duration; both sides prompt; it is asymmetric; and below Android 13 it still wants location permission. Worth building as an explicit "send this now, quickly" action — not as a rung the router picks silently, which would take a user's internet away without asking.
It matters more than NFC did, because of J46's exposure argument: direct Wi-Fi is the more private rung — no third party in the path — and shared LAN is the one that hands timing and MAC addresses to whoever runs the café network. The rung users would most want is the one that cannot cross platforms.
#### On iOS it is nearly free, and that changes the order
LANTransport already uses Network.framework — NWListener and NWBrowser over Bonjour, not the deprecated NSNetService. Setting NWParameters.includePeerToPeer = true makes the same code discover and connect over AWDL with no infrastructure network present. That is a small change against existing, working transport code, and it gives iPhone↔iPhone direct Wi-Fi — which is precisely Don and Lusmar's pair.
Android's WifiP2pManager is a separate and much larger piece: its own discovery model, group negotiation, and a group owner. Not comparable in cost.
But it is not a flag flip, and the trap is worth naming. Setting includePeerToPeer on the existing LANTransport would make one transport carry both rung 0 and rung 2, with no way to tell which a given connection used — so every direct-Wi-Fi delivery would be reported as sharedLAN. That is precisely the distinction J46 says matters most: direct has no third party in the path, shared LAN hands timing and MAC addresses to whoever runs the network. Misreporting it would corrupt the one signal the check-mark colour exists to carry.
Attribution has to come from the interface actually used — AWDL appears as its own interface (awdl0 on iOS) rather than as .wifi — or from two separately-parameterised listeners. Either is real work. Do not enable the flag without solving attribution first.
Therefore: do iOS peer-to-peer first because it is cheap, and treat Android Wi-Fi Direct as its own project. Do not let the channel bar imply the W light is available cross-platform — under J37 the letter must go grey when a path cannot carry, and for a mixed pair it can never carry.
---
### J49. Voice and video: the nearby case fits, the distant case does not. — Don
Don asked how WhatsApp's calling works, in the context of "maybe someday enable voip" (J32).
How WhatsApp does it. Signalling through their servers (offer/answer plus the call key, derived from the existing Signal-protocol session), then media peer-to-peer over UDP using ICE/STUN to traverse NAT, falling back to TURN relays when hole-punching fails — common on mobile behind carrier-grade NAT. Media is end-to-end encrypted with SRTP; a relay forwards ciphertext and sees only addresses and timing. Peer-to-peer leaks each party's IP to the other, which is why they added "Protect IP address in calls" to force relaying, at a latency cost. Group calls cannot be peer-to-peer and use an SFU-style server under a group key.
What that means here.
J31); relaying a call is a continuous stream, and it is the one workload that would make the server genuinely expensive. That is in direct conflict with the constraint the whole product is built around.PairingRendezvous is a signalling channel — an address two devices derive without a third party naming it.J44 deliberately makes optional. A call feature effectively requires push to be on. Decide that deliberately rather than discovering it during implementation.Status: not planned. Recorded so the cost is understood before anyone starts. The honest shape if it is ever built is nearby calls only, no relay — which is a real product ("talk for free with no network at all") rather than a worse WhatsApp.
---
### J50. Browse your own contacts; keep none of them. — Don
Don, 3 Sep 2026: "I already said you needed to allow a user to browse his own contacts but no saving or uploading of contacts is allowed."
This reverses J38 in one specific way and leaves the rest standing. J38 chose the system contact picker precisely so no permission was needed and no address book was read, and Model/PhoneContactSource.swift carried a prohibition against restoring a bulk read: "Do not add a fetch-all method back to this protocol. If some future feature seems to need one, it needs a privacy-policy change and an owner decision first, in that order."
Both now exist, and the order was honoured: store/privacy-policy.md §2 and §9 and store/data-safety.md were rewritten in the same commit as the code, not after it. That sequencing is not ceremony — earlier the same day the live privacy policy claimed NFC pairing the app could not do, and the lesson was that a published document contradicting the binary is the failure mode to design against, not a paperwork step.
#### What "no saving" is enforced by
| Claim | What makes it true |
|---|---|
| Not persisted | PhoneContactBrowser holds state in memory; nothing is written to storage |
| Gone when the screen closes | clear(), called on selection and on Done, with a test that fails if state survives it |
| Not uploaded, not hashed | No code path takes it anywhere; J38's "no contact matching, not hashed" still holds |
| Minimal in the first place | Three keys fetched — given name, family name, organisation. Not photographs, postal addresses or birthdays |
What survives a browse is exactly what survived the picker before it: the display name of the one person tapped, saved as if typed.
#### The Data Safety answer does not change, and that is worth stating
Play asks about data leaving the device. Nothing does, so Contacts: not collected remains correct. What was wrong was the explanation, which said no permission is requested. store/data-safety.md now flags that if Play asks about access as distinct from collection, the answer is yes.
#### Both routes stay
The picker needs no permission and returns one person; browsing needs Contacts access and shows the list. A user who declines loses the second and keeps everything else — and the denied state says so, naming a remedy and an alternative rather than dead-ending, which is the defect the pairing code-entry screen was fixed for the same day.
Android parity is owed and is not done: READ_CONTACTS and the equivalent screen. The permission model differs enough that it is its own piece of work.
---
### J51. The check cadence governs the foreground rate too, so Check Now has a job. — Don
Don, 3 Sep 2026: "we said when the app is open that we're polling for messages once a minute yet we have a check now button. check now button has no purpose if you're checking once a minute. the only place check now is a factor is if you've set the config to poll some time longer than a minute."
He is right, and this corrects J18. J18's table marked the foreground layer "No — the user is waiting" and fixed it at 90 seconds. The consequence was a button that could never matter: no choice a user could make left a gap worth closing by hand. That is the same defect as a Bluetooth toggle over an unwired transport or a Default sound that plays nothing — a control that cannot affect anything — and this is the third one found in a day.
#### The options, as Don specified them
Every minute · about every 15 minutes · about every 30 minutes · about every hour · only when I tap Check Now. Most frequent first; never last.
#### Two layers, one setting, and only one of them can honour the fast end
| Layer | Rate |
|---|---|
| App open | Exactly what was chosen. The app owns its own clock here, which is why these labels can be exact where the others hedge. |
| App backgrounded | The same choice, floored at fifteen minutes |
| Check Now | Immediately, always |
The floor is not a compromise, it is the truth. No phone wakes for a background check every minute, so asking the server for that would spend wake-ups the device cannot act on. Don had already said as much: "when the phone is asleep every 15 min is max possible and that isn't guaranteed of course." J13's rule — never print a number the system cannot keep — applied to what we ask the server for, not just to what we show.
.onDemand now means no timer in either layer, which is what makes Check Now the only way messages arrive on that setting, and therefore a control that matters.
#### One migration detail worth keeping
The raw values onDemand, slow and normal are unchanged, because they are what is already stored on shipped builds and the hand-written decoder falls back to the default on an unknown string — renaming them would silently reset the setting a user is most likely to have chosen deliberately. .slow moves from two hours to one: Don's list has no two-hour rung, and stretching it only widens the gap Check Now exists to close.
Android parity is owed.
---
### J53. Bluetooth is the signalling channel that starts Wi-Fi. — Don
Don, 3 Sep 2026: "figure out how you'd trigger/detect cross platform knowing that one or both might be (likely will be) using wifi for internet when the exchange needs to start to send a message direct."
That is the real engineering problem in J48, and it has a known answer: out-of-band handover. AirDrop does exactly this — BLE advertises and negotiates, AWDL carries the payload — and Android's NFC-to-Wi-Fi-Direct handover is the same shape.
BLE is the only channel that survives both phones being busy on infrastructure Wi-Fi, which is the normal case. So it is what negotiates everything else.
#### CORRECTION — and it changes what SoftAP is for
Don, immediately: "but ble isn't available at 100 feet and wifi is." Correct, and it undoes the framing above. If BLE negotiates the handover then Wi-Fi is only reachable within BLE range, which throws away the entire range advantage that motivated it.
The range lives in the same-platform paths, and those need no Bluetooth. AWDL discovers over Bonjour at full Wi-Fi range; Android's Wi-Fi Aware (API 26+) and Wi-Fi Direct do their own discovery likewise. Neither involves BLE at all.
Cross-platform has no shared discovery at Wi-Fi range — Wi-Fi Aware is Android-only, AWDL is Apple-only. So a mixed pair can only find each other by BLE (~30 ft), by already being on the same LAN, or through the server; and the last two mean they can already talk.
Therefore cross-platform SoftAP is not a discovery mechanism. It is a transfer upgrade between devices already in contact, and its value is:
J46's distinction: a café LAN hands timing and MAC addresses to whoever runs it#### SECOND CORRECTION — signalling and payload are separate concerns
Don again: "what about internet to wake the cross transfer while payload doesn't go on internet - wifi lan could wake the cross transfer even more securely than internet when it's available and ble if available (close) but wifi lan if farther and internet via server if no wifi lan connects the two."
That is the decomposition both earlier versions missed. The signalling channel and the payload channel are independent. Negotiation is a few hundred bytes — "let us go direct, here is the SSID" — and can travel over anything. The payload still never touches the internet.
So the range is not lost after all. LAN signalling works at full Wi-Fi distance, which means a mixed pair a hundred feet apart in the same building can negotiate a SoftAP handover.
| Signal over | Reach | What is disclosed |
|---|---|---|
| BLE | ~30 ft | Nothing — no infrastructure involved |
| Wi-Fi LAN | ~100+ ft | Whoever runs that network sees a brief exchange |
| Server | Anywhere | A deposit at a rotating token. Not who, not what (J46) |
Ordered by disclosure, and the ladder is chosen per attempt: nearest and quietest first.
The LAN case is a privacy upgrade, not merely a speed one. Two devices already on the same network could simply send over it — but J46 records that a shared LAN hands timing and MAC addresses to whoever runs it. Negotiating over the LAN and then moving the payload to a direct link means the network sees a short exchange and then nothing at all.
Wi-Fi's range advantage is therefore available cross-platform after all — but only where some channel already reaches, and the reach of the whole mechanism is the reach of its signalling. Any UI must promise the latter, never the former.
#### Reachable is not near, and the failure lands on the iPhone
Don: "we won't know if the two are in direct wifi range until the signal arrives and is acted upon so keep that in mind." Correct, and it is the operational constraint the design has to be built around.
| Signalled over | What it proves about distance |
|---|---|
| BLE | Within ~30 ft. A Wi-Fi handover will almost certainly succeed. |
| LAN | Same network — one room, or a whole building. |
| Server | Nothing. Could be different continents. |
BLE presence is the only reliable predictor. LAN and server signalling can initiate a handover but cannot forecast it, so those are attempts rather than plans.
And the cost of a failed attempt falls on the iPhone, which drops its Wi-Fi, fails to find a hotspot that was never in range, and reconnects — having lost internet for nothing. Three rules follow, which are really one rule (fail cheaply):
#### 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
| Pair | Mechanism | Costs the user |
|---|---|---|
| iOS ↔ iOS | AWDL via NWParameters.includePeerToPeer — runs alongside infrastructure Wi-Fi | Nothing |
| Android ↔ Android | Wi-Fi Direct on its own virtual interface | Usually nothing; older devices may drop Wi-Fi |
| Mixed | SoftAP: Android hosts, iPhone joins | The iPhone's internet, for the duration |
iOS↔iOS is also the cheapest to build — it reuses LANTransport's existing Network.framework code — so it goes first. It is also Don and Lusmar's pair.
#### The mixed-platform sequence
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.
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:
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.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.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.
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:
MAX_ENVELOPE_BYTES has to rise — see ATTACHMENTS-AND-COST.md.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.
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.
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.
Date: 3 Sep 2026 · Status: core built, platforms pending
Photos and files, no video. Video is deliberately absent from MediaType rather than reserved: it needs transcoding, codec negotiation, thumbnails and a player, and each is a place to leak metadata or ship a crash. It gets its own entry when it is built.
### The limits, and where each number comes from
| Constant | Value | Why this number |
|---|---|---|
ATTACHMENT_CHUNK_BYTES | 16,000 | Largest chunk whose encoded Payload still fits the 16,384 padding bucket. 16,384 itself would spill into the next bucket and double every transfer. |
MAX_ATTACHMENT_BYTES | 1,048,576 | A generous ceiling for a photo, not a target. Compression aims far lower. |
MAX_ATTACHMENT_CHUNKS | 66 | Derived from the two above, never written independently, so they cannot drift apart. |
MAX_ATTACHMENTS_IN_FLIGHT | 8 | Bounds the assembler, mirroring MAX_REASSEMBLY_BUFFERS. |
MAX_ENVELOPE_BYTES | 32,768, unchanged | The point of chunking is that this never has to move. |
Compression target for photos: about 300 KB at 2048 px on the long edge. The 1 MB ceiling is a bound, not an aspiration — a message that hits it should be re-encoded, not sent.
### Why chunking rather than a bigger envelope
Three properties a single large envelope cannot have:
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.
Date: 3 Sep 2026 · Status: attachment half decided and built; text half open
Don: "What do we do about compression of text messages, or does that save anything when we are padding packets?"
Measured rather than argued. Realistic non-repeating prose, bytes actually sent:
| Message | Bucket without compression | Actually sent | Saved |
|---|---|---|---|
| 10–160 B | 256 | 256 | 0 |
| 200–300 B | 1024 | 256 | 768 |
| 500–620 B | 1024 | 1024 | 0 |
So compression pays only in a narrow band — roughly 165 to 350 bytes — and nothing outside it. Below that everything lands in the 256 bucket regardless; above it, prose does not compress hard enough to drop a bucket. The earlier intuition that padding erases the benefit is mostly right, with a real exception in the middle.
What the saving is worth. 768 bytes at $0.13/GB is about one ten-millionth of a cent. Over the internet, compression is economically meaningless. Over BLE at a couple of KB/s it is closer to half a second on a subset of messages, which is the only argument for keeping it.
What it costs. The bucket transition is itself a content signal: a 256-byte payload now means either a short message or a longer one that compressed well, and "this compressed well" is information about content. That is the CRIME family, and it is why §5.1's forward rule already forbids sharing a compression context between authored and received text. The rule holds today only because there is no quoting or forwarding feature; both are ordinary things to want.
Decided now, for attachments: chunks are never compressed (payload.rs::encode). The uniformity property in J58 is otherwise true only by luck — JPEG and PNG bodies happen not to compress — and it would fail selectively on the one chunk holding a flat region, leaking exactly when the image is unusual. A test asserts a flat chunk and a noisy chunk encode to the same size.
Open, for text: whether to keep it at all. The honest summary is that it buys a rounding error of bandwidth, half a second on BLE, and a standing obligation to remember CRIME every time a quoting-shaped feature is designed. Recommend removing it when quoting or forwarding is specified, since that is the moment the obligation becomes load-bearing rather than theoretical.
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.
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.
Date: 3 Sep 2026
Don: "the records of course will need to have room to store things like when and how paired so we can show the level of security / certainty as to the id of the contact with whom we communicate and someone paired low security could get a higher level by using a better verification method one day when closer for example."
### The obstacle, and why it forces a second number
The rotating confirmation code (J52) is the strong check: it binds the handshake's ephemeral shared secret, so matching digits prove the exchange itself was not interposed. But handshake::Output's Drop scrubs that secret the instant pairing finishes — deliberately — and keeping it around so it could be re-checked later would trade forward secrecy for a convenience. So the rotating code can only ever be compared during pairing. Don's upgrade path is impossible with it.
Hence a safety number, derived from what both devices keep for ever: the two identity public keys, sorted, domain-separated, eight groups of five digits. Signal's design, and for the same reason.
The two are not redundant and neither replaces the other:
| Proves | Comparable | |
|---|---|---|
Confirmation code (J52) | the key agreement was clean | only during pairing |
Safety number (J62) | the keys match | at any time, for ever |
An interposer who substituted a key at pairing time cannot make two safety numbers agree, which is exactly what makes a later comparison worth doing.
### What is stored, and what is derived
Stored on the contact: the method (scanned in person, code read in person, code sent to you, split code, not paired), whether the two people have confirmed numbers, when that happened, and the cryptographic assurance the pairing itself provided.
IdentityConfidence is derived, never stored — verified / not verified / weak / none. Storing it would let the badge disagree with the facts underneath it, which is precisely the bug a summary field invites.
Four named levels rather than a percentage: a number would imply a precision nobody has and invite comparing two contacts as though the difference were measurable.
Method is fixed at pairing and never changes. Confirmation can be raised at any time, and the lateness is the feature — two people who paired by sending a code over WhatsApp can meet months later, compare eight groups of digits, and raise their own confidence without re-pairing and without touching a key.
PairingAssurance became Codable with an explicit string raw value, because an enum whose cases are persisted must not change meaning when somebody reorders the declarations.
### J63. Ten phones in a room, all pairing at once: addressing, not identification. — Don's stress test
Don: "there could be a dozen phones in the room with bt... if 10 phones are there and they are all showing a qr code right now and all looking for a bt signal to complete the pairing - this is the stress test." And: "does the phone know how it appears on bt?"
Answer to the second question first: no, and that is deliberate on both platforms. BluetoothAdapter.getAddress() has returned 02:00:00:00:00:00 to ordinary Android apps since Android 6; iOS never exposes the local BLE address, and what a peer sees is a per-central rotating UUID. Both platforms randomise BLE addresses precisely so an app cannot use them as a stable identity. A scheme that identifies the target device by its Bluetooth appearance cannot be built, on either platform, at any privilege level we will ever have.
It also is not needed, because we address a slot rather than a device. The rendezvous token is
SHA256("channel/pair-rendezvous-v1" ‖ displayedPublicKey ‖ attemptNonce ‖ direction)
Every displaying phone puts a fresh random 16-byte nonce in its own QR. So each of the ten phones is listening on an address derived from its own key and its own nonce. The scanner is the only other party that knows both, because it read them off the glass.
The nine wrong phones therefore:
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
ChannelBluetooth, ChannelWifi, ChannelInternet — measured under J27, not to be adjusted by eye).J21 and not yet implemented). A permanently dark letter is honest, but if it tests as confusing, hide the rung rather than fake it.Identical placement, order, colours and letters on both platforms.
### J66. Neither app should ask for contacts permission at all. — Don found it
Don, on the iOS Phone-contacts screen: "it shows a bunch of cute icons above and a choice to select or share but there is no escape from the screen and it's not clear who exactly you are sharing with or to." And then the question that settles it: "says allow access to contact? You already had access to the contact or you could not have selected it right? What exactly are you asking here?"
He is right, and the prompt is not asking what it appears to ask. It is not asking to read the contact he just chose. It is asking him to grant Channel standing access to that contact's record. It appears because the app declares NSContactsUsageDescription and calls CNContactStore, and on iOS 18+ a contacts request is answered with the limited-access picker — a permission grant wearing a picker's clothes.
#### The screen is doing two contradictory things
| Button | Mechanism | Permission needed |
|---|---|---|
| Choose One from Contacts | CNContactPickerViewController | none — runs out of process, returns one name |
| Browse My Contacts | CNContactStore (PhoneContactBrowser.swift) | full contacts access |
The first is the design this project already argues for everywhere: the app never sees the address book, the user picks, one name comes back. The second is the only reason Channel asks for contacts permission at all — and it produces the confusing screen, the inescapable sheet, and a usage string promising we do not keep what we just asked permission to take.
#### Decision: delete the browse path on both platforms
iOS: remove "Browse My Contacts", remove PhoneContactBrowser.swift's CNContactStore use, and remove NSContactsUsageDescription from Info.plist entirely. One button remains: Choose from Contacts.
Android has the same out-of-process picker and we are not using it. Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI) returns a single contact under a one-shot URI grant, with no READ_CONTACTS permission. Today PhoneContactBrowser.kt queries CONTENT_URI directly and the manifest therefore requests READ_CONTACTS. Replace it with ACTION_PICK and delete the permission from the manifest.
#### Why this is the right trade and not merely a simplification
The browse list offers one thing the picker does not: search across the whole book inside our own UI. That is worth very little — the system picker has its own search, and people know it — and it is bought with the single most sensitive permission the app requests. Removing it means:
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
J66 the app cannot read contacts at all, so no sentence is needed about what it does with them.#### Worked examples
> Was (190): "Browsing asks your phone for permission and reads your contacts only while this > screen is open. Channel saves none of them and sends none of them anywhere — only the name of > whoever you tap." > Becomes: "Pick one person. Channel never sees the rest." — and after J66 the screen it > lived on is gone.
> Was (207): "Costs you: your device appears on our server. Turn it off and you appear nowhere > — but the server is the only internet pathway there is, so you will only reach people in radio > range." > Becomes: "Your device appears on our server. Off means radio range only."
> Was (195): "Asks for Face ID, Touch ID or your passcode when you open Channel after a minute > away. This covers the screen — it is not extra encryption, and your messages are already > encrypted." > Becomes: "Face ID or your passcode to open Channel." The "not extra encryption" caveat is > real and belongs in the info screen, not under the switch.
Both platforms, same strings, one pass. The rewrite is a session of its own and must not be done in a hurry — these sentences are the app's claims about itself, and a careless trim can turn a true one false. Shorten by deleting whole sentences, never by compressing a qualified statement into an unqualified one.
### J68. Contacts you can reach without meeting: discovery, verification tiers, and what it costs. — Don
Don: "the app has to allow casual comm with strangers and not just trusted people if it's going to be someone's goto messaging app and not just the one they use to cheat on their girlfriend."
He is right, and this is a strategic change rather than a feature. Today the product is unusable with anyone you have not physically met. That is a niche tool.
#### 1. One contact screen, which we do not have
Create a contact by typing the fields, or by populating them from the system picker (J66 — out of process, no permission), then editing what came back, then saving. On a saved contact: Delete · Edit · Share · Verify.
#### 2. "Pair" is the wrong word now, and should go
It was right when pairing was mandatory. Under this model connection is automatic and the face-to-face ceremony is an upgrade, so "pair" both overstates the requirement and sounds like Bluetooth headphones. "Verify in person." Signal says verify safety number; Apple says Contact Key Verification. Users arrive already knowing the word.
#### 3. The tiers exist and are not being shown
PairingProvenance and IdentityConfidence already model verified / unverified / weak / none with the method that produced them. Both contact lists render a binary "Paired"/"Not paired" over the top of it. Surfacing the real level — badge plus colour, per Don — is display work against a model that is already built and tested.
#### 4. Discovery by number or email: the part that costs something
To let someone find you by number, a slot must be derivable from that number. The number space is about 10¹⁰. J40 already states the consequence: "anyone — including our own server — can enumerate every number... We make it worthless by guaranteeing there is never anything at those addresses but a public key and a claim."
Three ways to go, and they are not equivalent:
| Enumeration | Convenience | Claim cost | |
|---|---|---|---|
| (a) Directory, WhatsApp-style | membership list falls out | highest | loses "no phone number" outright |
(b) Code-gated, J40 as written | impossible | needs an out-of-band code | none |
| (c) Write-only discovery | deposits possible, membership not observable | high | "not required" rather than "never" |
Recommend (c). Anyone may deposit an invitation at a slot derived from a number. Nobody can read whether a slot is occupied — there is no lookup, no "is this a user" endpoint, and a deposit succeeds identically whether or not anyone exists. The owner polls their own slot and collects. Enumerating all 10¹⁰ numbers therefore yields exactly nothing: you can shout at every number in the world and never learn which ones answered.
#### 5. Two honest consequences that must not be glossed
First contact by number is trust-on-first-use, and cannot be otherwise. The invitation cannot be encrypted to a key we do not yet have, so whoever polls that slot receives it — including somebody who wrongly claims the number. That is the same exposure Signal and WhatsApp carry on first contact, and it is exactly what "verify in person" later repairs. The tiers are not decoration; they are the mitigation. A contact reached by number must never display as verified, and the badge has to make that legible at a glance.
Proving you control a number means somebody sends an SMS to it. There is no way around that, and it means a provider sees the number at verification time. Per J40 the number is then used to derive a slot and discarded — it must never be stored beside an identity.
#### 6. The claim change, which is the real cost
The site and both store listings currently say "No phone number and no email address, at any point." After this it becomes "not required", with exposure opt-in and verified. That sentence is load-bearing on channelmessenger.net and in the App Store description.
Nothing here ships until that copy is corrected everywhere. Shipping searchable-by-number while the front page promises otherwise is the one failure this project cannot recover from — every other claim on that page is believed because the claims have been true.
##### J68a. Verification binds to the value, not to the field. — Don
Don: "any edit of email or phone requires reverification or it can't be used to attract incoming attention and thus can't be used as the only credential to send either if not verified."
The attack this closes. Verify a number you genuinely own; edit the field to your ex's number; you are now published as reachable at their number and collect the invitations meant for them. A verification that attaches to a field rather than to a value is an identity takeover with a text edit.
So verification is derived, never stored as a flag. Not phoneVerified: Bool beside a mutable phone, which is one careless save() away from claiming a proof that does not exist — the exact failure Contact.isPaired is written to make unrepresentable ("never from a stored flag that could drift out of step with the keys it claims to describe"). Instead the proof is held against the value it proves:
verified(handle) = proofs[handle] exists and is unexpired
Change the string and no proof matches, so the handle is unverified by construction. Void it the moment editing begins, not on save — otherwise there is a window in which an edited value still wears the old proof.
An unverified handle is inert in both directions. It cannot be published for discovery, and it cannot be the sole credential for an outbound first contact. It is a note to yourself, nothing more.
And verification of a handle is not security. A verified number makes you reachable; only a key exchange makes the channel authenticated. The two axes are independent, and conflating them is what would let a person read "verified" and believe something stronger than we mean:
| Gives you | Requires | |
|---|---|---|
| Verified handle | somebody can find you | SMS/email proof of control |
| Key exchange | nobody can interpose | in-person scan, or code out of band |
J68's badge must therefore show the identity tier (IdentityConfidence), never the handle tier. A contact reached by verified phone number and never key-exchanged is Not verified, and must look it.
##### J68b. Security is a gradient the user walks up, never a gate they pass through. — Don
Don: "the users on a case by case basis always weigh convenience vs security depending on the needs of the moment, message, or contact... if you're just getting a message re a hotel reservation you just made you are not going to want to spend a complex frustrating time doing a key exchange and pairing... you just want it to work and quickly no fuss."
The app is currently built the wrong way round. Pairing is mandatory, so the floor and the ceiling are the same height: you either do the full ceremony or you cannot send a word. A hotel in Spain will never do the ceremony, so today the app simply loses that conversation to WhatsApp — and with it the reason to have the app on your phone at all.
Four rules follow, and they are all about what not to do:
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.
J67 — no paragraph explaining the risk. An app that nags about a hotel booking teaches people to ignore the one warning that will matter.*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
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.@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.@pussylicker problem, in six languages, against homoglyphs and leetspeak. Telegram and Discord both spend real money here and neither has solved it.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
@delton57, but they cannot produce #K7M2.This is how Tor onion addresses and Bluesky DIDs work, for the same reason. The cost is that it is worse to say aloud and easier to mistype — real, but a check code catches a typo, whereas a registry cannot un-store an identity database.
#### Routing to the right profile is free either way
Incoming packets do not need routing. Each profile polls the slots derived from its own keys and handles, so a packet is only ever collected by the profile that could derive that slot. No server-side routing, and no cross-profile linkage — which is what J65 requires.
#### Recommendation
Do not build a chosen-handle registry. Either ship phone/email discovery alone (J68), which needs no server state at all, or ship self-certifying handles if the "reachable without exposing my number" case proves real in testing. The middle option — a chosen, unique, server-stored handle — buys memorability and costs the central property the product is sold on.
##### J68d. The request mailbox, and the temporary chat. — Don
Don: "each user should have a setting that allows them to optionally block messages from people not in their contacts and could even have a special mailbox for people wanting to message that you could approve (by making them a contact or a temporary chat) or could just block and be done."
J40 already required this — "unpaired inbound surfaces the way an unknown SMS does... the spam decision belongs to the person" — and it is unbuilt. Three things here are new and worth pinning.
1. It is mandatory the moment J68 ships, not optional. Opening discovery by phone or email without an inbox gate is opening a spam funnel. The gate lands in the same release as discovery or neither lands.
2. The temporary chat is the good idea, and it solves a problem we already had. Every other messenger offers accept-or-block, so the Spanish hotel from J68b becomes a permanent contact cluttering the list for years. A third option — talk, and let it lapse — fits the gradient exactly: the conversation works immediately, nothing is added to the address book, and it ages out on its own. This is what most stranger contact actually is, and no mainstream messenger models it.
3. The default must be "request", never "blocked". Defaulting to blocking strangers would break the hotel case that J68b exists to protect. Strict mode — nothing from outside my contacts — is opt-in, for people who want it and know why.
#### Two properties that come free, and one that does not
Free: the sender learns nothing from a pending request. Collection is client-driven and the server confirms nothing, so an uncollected request is indistinguishable from an undelivered one.
Free: blocking needs no server involvement. The server is blind, so a block is purely local — their deposits simply sit in a slot until TTL expiry.
Not free, and must not be claimed otherwise: because a blocked sender never receives a delivery confirmation, a determined one can infer the block. WhatsApp has the same tell — one tick, for ever. We should not pretend to solve it; sealed sender does not help, because the absence of a receipt is the signal. Say plainly that blocking stops messages reaching you, not that it is undetectable.
Rate limiting is per destination token, server-side (J40), which the server can do without knowing whose token it is.
###### J68c addendum — the argument that settles it, and it is Don's
Don: "if there's a real key exchange that chat can happen with no exposure of any identifying handle or number or email."
Correct, and it collapses the case for handles. A key exchange exposes nothing at all — no number, no address, no name, not even to our own server, because the destination token derives from the shared secret and rotates. So the "I met someone and we both want to talk without swapping numbers" case, which sounds like the reason to want a handle, is already served better without one.
What remains is only: findable by someone you cannot meet, and unwilling to give a number to. Real — a tip line, a support desk, a journalist, somebody advertising a service — but the smallest of the three needs and by far the most expensive, since it is the only one requiring us to run a namespace.
Decision: no handles in v1. It is the easiest feature to add later and the hardest to withdraw: a registry, once populated with real people's chosen names bound to verified numbers, can never be un-stored. If it is ever added, self-certifying only (@delton57#K7M2), never chosen-and-registered.
And the tiering is a better way to describe the product than the site currently manages. Three ways to be reached, and the user picks how much to expose — nothing, a number you already give out, or a name that is not you. The strongest option costing the least exposure is the whole argument, and channelmessenger.net does not currently make it.
##### J65a. Verification is device-wide; exposure is per-profile; copying a card must never copy exposure. — Don
Don: "a number already verified as yours doesn't have to reprove in another profile on the same machine... you could copy from an existing contact as the start and then change the fields you want to change."
Both are right, and each needs a guard.
#### Verification and exposure are different facts
| Scope | Why | |
|---|---|---|
Verified — this device proved control of +1-555-1234 | device-wide, reused freely | a local fact that leaks nothing; re-proving it per profile is friction with no benefit |
Exposed — this profile is findable at +1-555-1234 | per profile, and at most one | publishing it links that profile to the number for anybody who knows it |
Two profiles must never publish the same handle, and this is a hard constraint rather than a preference, for two independent reasons:
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.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:
#### And the documentation point, which is J67's rule 3
Don: "the docs need to explain well how all this works so people can choose intelligently." Yes — in the docs. One line at the control, the model explained properly in a help page. This is exactly the material that has been leaking onto settings screens and making them unreadable.
### J69. Three kinds of claim, never blurred. — Don's positioning, made operational
Don: "we do not need to overhype or over claim and can gain more trust being honest about limitations than we can by promising the moon with footnotes... even at our worst we'll be more secure than most competitors and at our best we'll be hard to match."
Right, and the intent is not the hard part. Everyone intends this. What fails is drift — a promise that started as "we have configured it not to" gets repeated until it is written as "it cannot", and nobody notices the sentence changed. So every claim we publish is one of three kinds, and the kind is stated, not implied:
| Kind | Enforced by | Example | If we are lying |
|---|---|---|---|
| Architectural | mathematics | the relay cannot read your messages | provable by anyone with the protocol |
| Policy | our configuration and our word | we do not log IP addresses | undetectable from outside |
| Aspiration | nothing yet | we would resist a demand | worthless as evidence |
The rule: never let a policy claim be phrased as an architectural one. "We do not log IPs" is true and it is netlify.toml's doing; it is not the same species of statement as "we hold no key". A reader who cannot tell them apart cannot calibrate, and when they eventually learn the difference they will discount everything — including the claims that were airtight.
#### Where this bites us today, specifically
netlify.toml disables it. Our host could log regardless; nothing but configuration stops it. The site should say so in those words.MLKIT-TERMS.md) would end it.J68d) — say it stops messages reaching you, never that it is invisible.J68), and a subpoena reaches different amounts in each case. The three-row table in that entry belongs in the white papers close to verbatim.#### What this buys, and it is the actual strategy
A product that names its own limits gets believed about the things it does not qualify. That is worth more than any superlative, and it is the only durable advantage available to a small team competing with companies whose security claims are underwritten by their marketing budgets.
##### J69a. Name the adversary, name what we do not control. — Don
Don: "protect what you can protect, control what you can control but realize we aren't kings of the universe either... if you're in the business of doing things that attract nation state security agencies you can expect nation state resources... but if your concern is the jealous girlfriend or your employer using wifi sniffers we gotcha covered."
This is the shape the white papers should take. Not "Channel is secure", which means nothing, but "against this adversary, here is exactly what happens."
#### The ladder, honestly
| Adversary | How we do |
|---|---|
| Advertisers, data brokers | They get nothing. No SDKs, no identifiers, no telemetry to buy — structural, not policy. |
| Someone on your Wi-Fi | Covered. Content is sealed on every path. The LAN rung reveals that you run Channel to others on that network (J21 says so, and the setting can be turned off). |
| A partner with your unlocked phone | Screen lock (AppLock) helps and is honest about being a screen cover, not a second layer of encryption. |
| Your ISP or a network observer | Content sealed; ECH hides which host you asked for only if the API is fronted by Cloudflare, which today it is not (CLOUDFLARE-SETUP.md). |
| A subpoena to us | Little to hand over, and less for a key-only profile than a handle-bearing one (J68). |
| A subpoena to Netlify, Telnyx, Apple, Google | Outside our control entirely. |
| A nation state with device access | We lose, and so does everyone else. Say it plainly. |
#### What we do not control, named rather than gestured at
netlify.toml disables access logging; Netlify could log regardless. Policy, not architecture (J69).#### And the framing Don gave, which should survive into the copy
Risk tolerance against utility, chosen per use — like a password. Nobody uses a 40-character passphrase for a crossword site. Our job is to make the strong option available and cheap, the weak option honest about being weak, and the choice legible — not to force everyone to the top of the ladder and lose them (J68b).
### J70. Screen capture, disappearing messages, view-once — and the camera pointed at the screen. — Don
Don: "there are ways to block screen recording and screen shots on the device but not recording from a camera or second phone so worth preventing just like expiring messages that whatsapp allows and single view photos even again with same limitation above."
Worth building, all three. The reason this needs an entry rather than a line on a list is that the two platforms can do very different amounts here, and saying so is J69's rule in its first real test.
#### Android can actually block it. iOS cannot.
| Android | iOS | |
|---|---|---|
| Block screenshots | FLAG_SECURE — genuinely prevents it | no official API, but the secure-layer technique works — see correction below |
| Block screen recording | FLAG_SECURE covers it | same technique covers it |
| Hide from the app switcher | FLAG_SECURE covers it | possible by other means |
| Detect a screenshot after the fact | no | userDidTakeScreenshotNotification — after, never before |
| Detect screen recording | no | UIScreen.isCaptured, and content can be blanked while it is true |
CORRECTION, same evening — Don sent a screenshot that disproves the row above. He captured WhatsApp's Contact info screen on iOS and the capture came out black, reading "Screen capture blocked — To protect everyone's privacy on WhatsApp, this screen capture has been blocked."
So iOS blocking is achievable and is shipping at WhatsApp's scale. The mechanism is the isSecureTextEntry layer technique: a secure UITextField's layer is excluded from the capture pipeline, so real content added as a sublayer of it renders normally on screen and black in any screenshot or recording. It is undocumented, and I dismissed it too quickly on that basis. Apple plainly tolerates it, and it is in wide production use.
Two things the screenshot teaches beyond "it works":
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
WHATSAPP-GAPS puts them in Tier 3 for that reason. Deleting on a timer must also delete from the store, not merely hide.##### J70a. The blocked-capture card is where the honest caveat belongs. — Don's screenshots
Don confirmed the mechanism by using it: "try to screen shot a profile photo in whatsapp and you get an image of something else and the screen shots saved are the images i sent you." The two images he sent are the substituted output — and the first shows iOS's Markup editor already holding the replacement card, so the substitution happens at the compositor before the editor ever sees it. Nothing is detected and deleted after the fact; the file written to Photos was never the real content.
#### Put the caveat on the card, not under the control
J70 says the camera-pointed-at-the-screen limitation needs saying, and J67 says settings screens must stop carrying paragraphs. Both are satisfied by putting it where WhatsApp puts its branding:
> Screen capture blocked > Someone can still photograph this screen with another camera.
That sentence reaches a person at the exact moment they tried to capture something, which is the only moment they are actually thinking about it — and it costs zero space in the ordinary interface. It is the most honest possible placement, and it is free.
#### Which screens get it, and which deliberately do not
| Screen | Blocked? | Why |
|---|---|---|
| Expanded profile photo | yes | Don, on where WhatsApp actually does it: "it was the profile image after i expanded it" |
| Avatar thumbnail in a list | no | incidental at that size, and blocking it would black out whole list screens |
| Contact info (the card itself) | no | Don, testing it: "they let me screen capture the entire contact card that includes the little profile pic but not the expanded version" |
| View-once media | yes | the entire feature is void without it |
| Your own pairing QR / code | no | people legitimately screenshot it to send by another channel, and we support sharing it as text anyway |
| Safety numbers | no | comparing them by screenshot over a video call is a legitimate verification path |
The rule the list follows: block a screen where the content is presented to be looked at, never where it appears incidentally. An expanded photo was opened on purpose; the same photo at 32pt in a contact row is furniture, and blacking out every list that contains one would be absurd.
Blanket blocking would be easier and worse. Two of these screens exist in order to be shared, and blocking them would break the ceremony J63 depends on.
Why the earlier reading of Don's screenshot was wrong, recorded because the mistake is instructive. The substituted card said Contact info at the top, so I concluded the contact screen was blocked. It is not. The expanded photo viewer is presented over that screen and inherits its navigation title, so the blocked capture wears the name of the screen underneath it. Blocking is narrower than the artefact suggests, and a substituted screenshot is evidence about what was on top — not about what the screen is.
Don also notes WhatsApp applies it in "a couple other places too". Settle our own list from the principle above rather than reverse-engineering theirs — but view-once media is certainly among them, since the feature means nothing otherwise.
### J71. One field that takes a handle, a number, an email, or a search term. — Don
Don, on starting a new chat: "you list the contacts but need a blank field too where a user would enter a handle, email, phone number, or a search term for the list of contacts that follows... much like WA — note the pattern here?"
The pattern noted. The same control keeps being needed in three places, and each has been designed separately or omitted:
| Screen | Today | Should be |
|---|---|---|
| New chat | list only, no field | one field |
| Contacts | no search at all | the same field |
| Add contact | separate "Add by Name" and pairing routes | the same field |
One control, in all three, doing one thing: you type, and it narrows or resolves.
J68), if the handle is verified and exposed.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
PairingPayload's isLegacy already does this — "Their Channel is out of date" — and that is the pattern. Refusing is acceptable; refusing silently is not.#### What may move server-side, and the line that must not be crossed
Don's instinct is right and it has a hard boundary, which is the same split as J69:
| May be server-controlled | Must never be |
|---|---|
| TTLs, rate limits, poll cadence hints | which cipher or key agreement is used |
| Minimum-supported-version notices | key lengths, padding buckets that affect confidentiality |
| Endpoint routing and retry policy | whether a message is encrypted at all |
| Feature flags for UI and product behaviour | anything a downgrade attack could exploit |
Every hook that lets the server change client behaviour is a hook an attacker or a compelled operator can pull. A server that can tell a client which algorithm to use can tell it to use a weak one, and our own threat model (J69a) explicitly includes a subpoena to the host. So: the server may change policy; it may never change cryptography. Anything in the right-hand column stays compiled into the binary where a server compromise cannot reach it.
That still leaves real value in the left column — most operational emergencies are timing, load and routing, not cryptography — and it is the honest version of Don's "not all web type fixes have to be visible to the user."
##### J72a. Additive, not breaking: an old version may miss a feature, never lose one. — Don
Don: "we could require a software update if something really bad or really good happens... but to the extent possible we don't want to do things that break old versions for the key version dependent things. an old version could miss a feature but what the old version did well should hopefully still work as it always did if new versions gain capability."
That is the rule, and the pairing payload is the counter-example to learn from.
Growing the code from a 32-byte identity key to a 48-byte key-plus-nonce was made a breaking change when it could have been additive. What actually happened tonight: the old build rejects 48 bytes, the new build rejects 32, and two Channel users can never reach each other in either direction.
What additive would have looked like:
[version byte][32-byte identity key][optional trailing fields...]
An old client reads the identity key it understands and ignores the tail. It loses the rendezvous — it still needs the second scan, a missing feature — but pairing still works, which is what it did well before. The new client sees no nonce, notes the peer is old, and falls back to the two-scan ceremony automatically. Nobody is stranded and nobody is told to update in order to do a thing they were already doing.
The design rule, for every wire format from here:
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:
stop();Completed;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;That is how a device ended up with the same contact listed twice.
Two changes, and the second matters more than the first:
stop() sets State.Idle. A stopped listener is not a completed one, and a dismissed dialog stays dismissed.##### 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):
J39).##### 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:
setConversationOpen, told by the view);expectReplySoon, called after every successful deposit — sending is the strongest signal a reply is coming).Both are gated on the app being in the foreground and on the cadence not being ON_DEMAND, so "no timer" still means no timer. The tick now sleeps min(tick, interval) so a 15 s tick cannot quietly cap a 5 s interval. Identical on both platforms; on iOS it rides TransportControls with a default no-op so the radios need no change.
Cost. Idle on the conversation list: unchanged. In a chat: 12 requests/min per device, only while the chat is open. That is the honest price of "instant" without push, and it is paid by exactly the people getting the benefit at the moment they want it. If it ever matters at scale, the right next step is long-polling on /v1/collect — the server holds the request open until mail arrives or ~10 s passes — which gives lower latency and fewer requests than any client-side interval. Not built now; noted so the option is not forgotten.
What "instant" means here. Sub-5-second arrival, measured below. Not zero: that requires the server to push, which J9 rules out for a stable identifier and J10 deliberately does not do.
##### J77a. Adaptive decay: a conversation becomes an email. — Don
Don, refining J77: "every message sent triggers 5 one minute polls followed by 5 2 min followed by 5, 10, 20 etc if no more messages are sent or received, with sends vs receives having different effects... a receive might trigger quick checks briefly as sometimes people send more than one message back to back... when you send the polling pace stays rapid a bit longer as you're waiting to see if a quick reply is coming, and if it takes more than a few min to get the next reply you can afford to wait longer as it's no longer a conversation and is more like an email."
Built as stated, on both platforms, with the same numbers:
| Since last send | Poll every | Since last receive | Poll every | |
|---|---|---|---|---|
| < 5 min | 1 min | < 2 min | 1 min | |
| < 15 min | 2 min | < 6 min | 2 min | |
| < 40 min | 5 min | < 20 min | 5 min | |
| < 90 min | 10 min | after | floor | |
| after | floor |
The two ladders are read separately and the shorter wins. A conversation on screen in the foreground still drops to 5 s (J77). The user's cadence setting is the idle floor: activity may only make polling more frequent than the setting, never less — a person who chose "Every minute" gets every minute at rest, and "About every 15 minutes" now means at rest.
Platform truth. Android runs this schedule foreground and background, because MeshService is a foreground service. iOS runs it only while the app is alive; a backgrounded iOS app cannot poll on a timer, and nothing here pretends otherwise.
"Don't let the phone sleep": no. On iOS it exists only while the app is on screen, where this schedule already applies; on both platforms it costs the battery more than it saves the server.
On observability. Don: "i have my doubts that they'll learn much more from this than they would from every min polls." Agreed. J9 already concedes the poll is the one thing the server sees; a rhythm that decays after activity reveals that activity happened, which the deposit that caused it already revealed.
##### J78. Deleting a contact leaves the page. — Don
Don: "deleting should bring you back without having to hit the left arrow to leave the screen." iOS ContactDetailView deleted the contact and stayed put, falling into its "Contact Removed" placeholder — which in dark mode reads as a blank screen. Now dismiss() follows the delete, and an onChange(of: contact == nil) pops the page if the contact vanishes for any other reason — the same shape as Android's LaunchedEffect(contactId) { onBack() } from earlier tonight. Android re-verified live: delete returns straight to the Contacts list. Shipped as iOS 2.004 (10).
##### J71a. Built: one field on the Contacts list, both platforms.
The same control on Android ContactListScreen and iOS ContactListView (which is also the New-Message picker, so all three of J71's screens get it from one implementation): a field above the list, placeholder "Name, number or email". Letters narrow the list by name. When nothing matches, one row offers Add "<what you typed>", which creates an unpaired contact and opens their page with Pair on it. Not offered beside a partial match — typing "Dev" to find DeviceA must not also propose a new "Dev". The separate iOS "Add by Name" sheet still exists behind the + menu; it is now redundant and goes when the contact editor lands. Numbers, emails and handles are treated as names until Phase 6 discovery exists — there is nothing yet to look them up in.
Verified live on Android: filter, add, detail, return. iOS builds; same code shape.
##### J79. The contact card: name, phone, email — local only, filled by hand or from the phone's picker.
Don: "a screen on contacts where you create a contact and you can fill in the text fields or select a contact from your phone to populate the fields." Built on both platforms:
Contact gains optional phone and email. Local only, like the name: never sent to the peer, never to the server, never used as an address. Discovery (J68) will need a verified identifier, which is a separate later thing — a typed number on a card proves nothing.J72a): old stores decode unchanged, defaults are null.J66, still no contacts permission) now returns name + first phone + first email for the one contact chosen, and they land on the card. On Android that is one more query under the same one-shot URI grant; on iOS the CNContact the picker hands back already has them.J67). iOS: phone and email are edited in place under the name, exactly as the name already was.Verified live on the Android emulator: edit, save, card, and the values on disk.
##### J68e. Built: Delete · Edit · Share · Verify in person on the contact page, both platforms.
"Pair with X" is now Verify in person (Android) / Verify in person with a seal icon (iOS). Share sends the card as plain text — name, phone, email — through the system share sheet; nothing about keys, because a shared card is how to reach someone, not an identity claim. Edit is the pencil (Android) or in-place fields (iOS); Delete was already there. Stale copy promising "the same six numbers" corrected — the confirmation code has been twelve digits since the hostile review. Verified live on Android (labels, and the chooser opens with the three lines). iOS builds.
##### J70b. Built: screen-capture blocking on both platforms, on by default.
Android: FLAG_SECURE on the window, applied the moment the setting changes. Verified on the emulator — a screenshot of the app comes back blank. iOS: the whole app sits inside a secure-text- entry layer (ScreenCaptureShield), the mechanism J70a observed WhatsApp using; the compositor substitutes a blank in screenshots, recordings and the app switcher. If the canvas layer cannot be found the content shows unshielded rather than blank — a messenger that hides its own messages from its user has failed harder than one that can be screenshotted. Settings: Block screenshots, one line under it: "A camera pointed at the screen is not stopped."
##### J67a. Copy cut, first pass: Contacts stripped, Settings one line per control, both platforms matched.
Don: "on ios app you still have explanatory prose we don't need on the contacts page for example and too much on settings. eliminate from contacts page and shorten a lot on settings page and match on android of course." Contacts carries no sentences now — the self row says "You", the empty state says "Type a name above to add someone." Every Settings helper is one short line with the same words on both platforms; the pathway explainer is three lines. Comments in the code keep the reasoning the screens no longer carry.
##### J68f. Built: identity-confidence tiers instead of the paired/not-paired binary, both platforms.
Every contact row and detail page now says Verified, Not verified, Weak pairing or Not paired. Android gains the provenance fields iOS already had (assurance, numbers confirmed, when); both platforms now record them at pairing — which iOS never did, so every iOS contact had been reading as a weak pairing under its own model. Tapping "Numbers Match" on a rendezvous pairing is the comparison, so it records Verified. A contact stored before these fields existed shows Not verified, never Weak (J72a): weak is a claim about how a pairing was made, and an absent record makes no claim. Verified live on two emulators: fresh pairing → Verified on both; pre-existing → Not verified; unpaired → Not paired.
##### J65c. Built: the channel letter on every conversation row; All / Paired as one control on both platforms.
J65 asked for the status letters to be always visible. The live bar inside a conversation already existed on both platforms; now each row on the Messages list also carries the letter of the channel the last message actually travelled (B, W, L or I), beside its time — the same MessageChannelMark both platforms already used inside threads. And the Contacts filter is the same control on both: a segmented All / Paired row (Android had a "Paired only" chip).
##### J80. Pairing code: version byte, 8-byte nonce — 66 characters, still base32. — Don
Don, asked whether to go denser: "yes do as you suggested." The payload is now [version 0x01][32-byte identity key][8-byte attempt nonce] — 41 bytes, 66 Crockford base32 characters, down from 77. Eight bytes is 64 bits of unguessability for a slot that lives minutes; the version byte is J72a applied before anyone is public. Decoders accept longer payloads and ignore the tail. Not base58: it would buy about nine more characters at the price of case-sensitivity, which phone keyboards and read-aloud both punish, and would give up the no-I/L/O/U property that makes the code retypable. If testing shows people type codes rather than scan or Send them, the bigger lever is an online-only short code carrying just the nonce (~26 characters) with the key travelling through the rendezvous slot — a second format, deliberately not built yet.
2.006 and earlier cannot read the new code and this build cannot read theirs; nobody public is affected, and the version byte is what prevents this from happening again.
##### J65d. Built (Android first): profiles — one identity and one store each, picker top-left, cap 10.
ProfilesStore keeps profiles.json above every profile's own directory; each profile has its own identity secret (keyed secret:<id> in the encrypted store) and its own conversations.json, attachments and spool. On first run the one legacy identity and store become profile "Me" — moved, not copied. Switching tears down the previous profile's store and transports and binds the new profile's, and the navigation host is keyed on a generation counter so no screen keeps stale state. The avatar (initials on a colour derived from the profile id) sits top-left on Messages, Contacts and Settings; Settings shows the profile name in its header; the pairing screen says "as <profile>" beside the avatar (J65 §3). Deleting is behind a typed confirmation and destroys the directory and the identity secret; the last profile cannot be deleted.
Verified live: migration kept every contact; a new profile shows a different pairing code (its own key) and an empty list; switching back restores everything; delete returns to "Me". Not yet: photo avatars, the self card travelling inside the handshake (J65 §2 — a protocol change), per-profile exposure (Phase 6). iOS mirror follows.
##### J81. iOS app lock: Face ID was never offered, and the Unlock button was unreadable. — Don
Don: "ios lock screen presents for code but doesn't do face id and has no option for that... when you dismiss the codes it shows a small button that says unlock but contrast very poor and kind of pink." Two causes. NSFaceIDUsageDescription was missing from Info.plist, and iOS's answer to that is to downgrade deviceOwnerAuthentication to passcode silently — no error, no Face ID prompt. Added (Info.plist and project.yml). The button used ChannelColor.primary, which in dark mode is the pale pink of the inverted palette; on the black lock screen that is the worst contrast in the app. Both platforms' lock screens now use the fixed garnet with white text, the one surface that deliberately does not follow the theme (ChannelColor.brandBarContainer).
##### J65e. Profiles on iOS: the same shape, as a ProfileSession.
ProfilesStore (profiles.json above the per-profile directories; legacy identity and store become "Me", moved not copied) and a ProfileSession that owns one profile's identity store, message store, transports, handshake and reachability monitor. Switching the active profile stops the old session and builds a new one; the root view is .id'd on the profile so every screen's state resets, and the environment carries the new session's objects. Each profile's identity is its own Keychain item (secret:<id>); deleting a profile removes its directory and Keychain item. Avatar top-left on Messages, Contacts and Settings; Settings' bar names the profile; the pairing title reads "as <profile>" under an avatar. Builds clean; unit suite run after.
##### J65f. Chats stay with their profile; badges for mail in other profiles; an optional merged view. — Don
Don: "have to make sure messages and chats stay with the profile and maybe have to have badges on the profile avatar to show messages arrived and when you open that profile menu... you'd get badges on each profile that had new messages. There should also be a setting config to choose between merging all the chats on one screen from all profiles with the profile menu choosing who sends a message vs separated (default) where each profile's messages stay on screen only when its profile avatar has been selected."
Consequence for the architecture: a badge for a profile you are not looking at means that profile must still be collecting. So every profile keeps a live session — its own store and its internet polling — and switching only changes which session the screens are bound to. Bluetooth and local-network radios stay bound to the active profile only (one radio, one set of contacts to answer as); the relay is what carries mail to a profile nobody is looking at. Cost is what J65b warned about: mailboxes polled = contacts across all profiles, per check.
##### 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:
+ 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."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).profiles.json), confirmed on the emulator.Don's framing, which is the standard for all of it: "These sorts of things are just the intuitive nature of how the app should work, so it'll all seem natural."
##### J83a. The self card is a normal contact that carries the user's name, marked "This is me". — Don
Don: "the me profile should also be a normal contact because that contact could include email addresses and telephone numbers and perhaps a handle... to let the user choose how this profile will be exposed to the world... I wouldn't call that contact me because it should display the user's name but with a checkbox on that profile which could be offered for any contact; if that check box says this is me, that becomes the first profile by default, and if somebody else gets selected, whatever was selected before gets unselected."
So: the self card is an ordinary contact card (name, phones, emails, later a handle) with the Find me by column (J83) — that is exactly where a person decides how this profile is exposed. A This is me checkbox on any contact card marks it as the profile's own card; checking it on another card unchecks the previous one. "Note to Self" stays as the name of the thread to yourself; the contact shows the user's name. The first profile's card is what the first-run flow asks for.
##### J83b. Settings follow the profile, with a Scope switch at the top of Settings. — Don
Don: "the settings should follow the profiles, because different profiles might be for different security needs... an option to choose whether the settings page applies to all profiles or applies only to the selected profile, that should probably be one of the first settings on the setting page — call it Scope." So: Scope: All profiles / This profile, first row of Settings. Under "This profile" the page edits an override stored with that profile; under "All profiles" it edits the shared set. A profile with no override inherits the shared set.
##### J65g. One protocol engine per profile — the shared one silently swapped identities.
MeshEngine (and the FFI on iOS) holds one identity and one node; restoring another profile's identity frees the first. With one engine shared across sessions, every profile's seal, open and token derivation ran as whichever profile had been restored last. Found on the emulator: a message for the inactive profile was collected at launch (when it happened to be the last restored) and never after a switch. Each ProfileSession now owns its own engine; the screens use the session's.
##### J83c. Which settings follow the profile, and which are a vote. — Don
Don: "if the settings only follow the profile, then the settings would change when you change profiles... if the background profile called for pulling messages at X time interval and another profile called for pulling more frequently, the more frequent would win... if one profile enabled Internet and another one did not, then that would just mean the one that did not, did not receive things by Internet... think about which settings really can't apply to the profile, and which ones would be more like a vote."
| Setting | Scope | How several profiles combine |
|---|---|---|
| Internet (relay) | per profile | gates only that profile's mailbox polling |
| Check cadence, quiet hours | per profile | vote: the most frequent wins for the device's wake schedule; each profile's own loop still honours its own |
| Read receipts, photos direct-only, notification sound | per profile | no combining needed — each applies to its own conversations |
| Bluetooth, local network | device (one radio) | vote: any profile on turns the radio on; only the selected profile answers on it |
| Relay for others | device | vote: any |
| Block screenshots, app lock, merged view, Scope itself | device | one window, one lock, one list |
The Scope switch (J83b) decides which set the Settings page is editing; the table decides how the device behaves when profiles disagree.
##### J83d. Avatars: stock, photo, or camera; initials when none. — Don
Don: "the avatar image needs to allow you to either pick some stock avatars... or select a photograph from your device or take a picture... same as every other app that has profile pictures and if no avatar or photo is selected, it shows the initials of the person, in my case for Don Elton it would just be DE." Initials are what both platforms draw today (first letter of the first two words). Stock set, photo library and camera are the next step for the profile card and the contact card alike; the image is stored with the profile, never sent anywhere except inside the sealed self card (J65 §2) once that exists. Recorded so both platforms get it in the same shape.
##### J84. Letters only, closer together; the thread title centred with an avatar; avatars travel in the payload. — Don
Don: "the status lights at the top of the screen on the Android show B and I illuminated while on the iPhone it only shows I... the plan was not to have dots and letters, but to just have colored letters versus grey letters so they can be much closer together... they show up right underneath the name of the person with whom you're chatting so that name should probably be centered and show an avatar if there is one to the left of the name... those avatars would be exchanged during the pairing or after receiving the first text message from that user, the avatar would be part of the payload and once acknowledged would not be included in the payload again unless it had changed on the other phone... if I'm on WhatsApp and somebody changes their profile picture, I see it pretty much as soon as they change it."
J37 said so; both platforms still draw a dot beside the letter). Coloured when lit, grey when not, tight spacing. Both platforms.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:
tel:, facetime://, facetime-audio://, whatsapp://send? phone= (and https://wa.me/), Signal's sgnl://, Telegram's tg://resolve?phone=. iOS requires each scheme to be declared in LSApplicationQueriesSchemes before the app may even ask whether the other app is installed; Android uses ACTION_VIEW on the same URLs (and package intents). What is not guaranteed: that the target app starts a call rather than opening the chat — WhatsApp's public scheme opens the conversation; the call button is one tap further, and any "direct call" scheme is unofficial and can change. Say so in the UI rather than promise.##### J86a. Hand-off research: what each app's scheme actually does with a number. — Grok (live search), 4 Sep 2026
Findings, per platform, for the apps J86 names. The rule that came out of them: the card carries marks only for apps that can take a number at all, and the button says "Open in …" rather than "Call", because most of these open the chat and the call is one more tap.
| App | Takes a number | What happens | Notes |
|---|---|---|---|
| Phone dialer | tel: (both) | Dials after the OS asks (iOS) / opens the dialer filled in (Android ACTION_DIAL) | No permission needed for ACTION_DIAL. |
https://wa.me/<digits> (both), whatsapp://send?phone= (iOS) | Opens the chat; call button is one tap further | No official direct-call URL; unofficial ones break. | |
| FaceTime | facetime://<number or email>, facetime-audio:// | Starts the call (iOS only) | Not on Android; the FT mark is hidden there. |
| Signal | https://signal.me/#p/+E164 (sgnl:// on iOS) | Opens the chat | Fragment, so the number never reaches Signal's server. |
| Telegram | https://t.me/+E164, tg://resolve?phone= | Opens the chat, subject to their privacy setting | |
| Google Meet | meeting codes only | — | No documented number-carrying 1:1 scheme any more; not offered. |
| Zoom | zoomphonecall:// (Zoom Phone licence only) | Dials | Consumer Zoom is meetings-only; not offered. |
| Skype / Teams | skype:+E164?call, Teams deep link | Dials | Niche; not offered in the first cut. |
Platform requirements: iOS must list every scheme it will ask about in LSApplicationQueriesSchemes (whatsapp, sgnl, tg, facetime, facetime-audio); opening without asking needs nothing. Android 11+ must declare the packages or intents it queries in <queries> (com.whatsapp, org.thoughtcrime.securesms, org.telegram.messenger, plus an ACTION_DIAL/tel: intent so the default dialer is found without naming OEM packages). Numbers are passed as E.164; the app strips everything but digits and keeps the leading +.
##### J83e. The contact page, as built. — Claude, 4 Sep 2026, from Don's list
Contact grew phones/emails (lists of ContactField { value, findMeBy, apps }), scope (All profiles / This profile), and avatar (stock glyph or a JPEG in the profile's avatars/). The single phone/email fold into the lists at load and are then null (J72a: additive).+ is on Contacts only and opens the form. The Chats bar lost its Add button.J86 app marks instead. Reason: "find me by" is a fact about you; the marks are a fact about them.?profile= on the route), the way J65f's merged chat list already does. The card is never copied between stores.##### 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
PROTOCOL.md §5.5 key 5's rule. A change is carried to the peer as a payloadType 4 policy update with body {"v":1,"disappearAfter":<seconds>} -- the FFI does not yet expose the policy submap, so the update is its own small payload rather than a piggyback. Convergence is lazy, as §5.5 says: new messages only, and the dialog says so.expiresAt when composed or when it arrives; a sweeper deletes past-due messages and their picture files at load, at start, and every thirty seconds while the app is up, scrubbing the old document (save(scrubPrevious)) so a disappeared message is gone from the disk as well as the screen. J70's limit is on the dialog in one line: they may still photograph the screen.##### J89. View-once pictures, as built. — Claude, 4 Sep 2026, from J70
{"v":1,"viewOnce":"<SHA-256 of the picture bytes, base64>"} ahead of the slices; when the receiver's assembler completes a picture whose hash is on that short list, the picture is view-once. The assembler does not expose the attachment id across the FFI, and the bytes are the one thing both sides can hash independently -- so no native rebuild and no wire change.J70). Both phones store the file under a fresh AES-256-GCM key that lives only in the message row; the sender's copy is decrypted in memory to seal the slices and the file and key are deleted the moment the message is SENT -- the sender does not get to look again, WhatsApp's rule. The receiver's row is a chip, "tap to view once"; the viewer shows the picture full-screen from memory, and closing it deletes the file and the key and leaves a chip that says "Opened". The document is scrubbed on the way out so the key is not left in the old file.J70b); J70's limit -- another camera -- stands and is stated once, on the disappearing-messages dialog, not repeated here.##### J90. Direct Wi-Fi, as built -- and what is not yet known about it. — Claude, 4 Sep 2026, from J21 rank 1
DirectWiFiTransport.swift), service type channel-mesh, a random peer name per launch, both phones browse and advertise, the lower name invites. Android: Wi-Fi Aware (DirectWifiTransport.kt): publish and subscribe under the same name, one "hi" / "port:n" exchange, a data path, an ordinary socket over it. Wi-Fi Direct was passed over because it needs a system dialog per connection.LanPeerAuthenticator -- so a stranger's Channel phone learns that a Channel phone is near and nothing else, and no envelope moves until a sealed blob proves a contact.J21's table says: iPhone to iPhone, Android to Android. The rung sits at rank 1 in the ladder and lights W; it is on by default (it touches nobody's infrastructure) and switchable under Ways to reach you. Android asks for NEARBY_WIFI_DEVICES (13+) or fine location only on phones with the Aware radio; phones without it never light W.J21's own caveat stands: whether a peer link degrades the infrastructure connection is chipset-dependent and "believed, not measured". First thing to try on two real phones of the same kind: airplane mode with Wi-Fi on, send a photo, watch for W.##### J64a. The beacon, and the flights over the radio, as built. — Claude, 4 Sep 2026
J64's beacon is advertised exactly as specified: SHA256("channel/pair-beacon-v1" ‖ key ‖ nonce) [0..16] worn as a service UUID while the code is on screen, and only then. Android carries it in the scan response (a second 128-bit UUID does not fit beside the first); iOS advertises it as a second UUID, which lands in the overflow area that other iPhones read in the foreground. A scanner that has just read a code computes the same UUID and looks for the phone wearing it.
And the flights now travel over Bluetooth as well as the relay. J63 already said the offer "can be shouted into the room over BLE" because it is legible to exactly one phone; so it is. A pairing flight on the radio is a small frame -- "CHPR" ‖ slot token (16 bytes) ‖ payload -- the Bluetooth transport recognises by its magic and hands to whoever is pairing rather than to the message store. The scanner sends flight 1 to the beacon's phone if it has seen it, else to every Channel phone in range, and deposits it on the relay too; the displayer answers on the path the flight came by (and the relay); whichever path finishes first wins. With no internet at all, the radio is the only path, and pairing still completes. Verified on the emulators that the relay path is unchanged (a full pairing with confirmation digits both sides); the radio path needs two real phones, like J90.
##### J91. Phase 6 as a plan: write-only discovery by number or email, and what it needs from Don before a line is written. — Claude, 4 Sep 2026, proposal
Everything above Phase 6 on the list is built. Phase 6 is the one block that cannot be started autonomously, because it spends money and changes a public promise. This entry is the plan, so the decisions can be made in the morning and the build can begin the same day.
What gets built (from J68 (c), J68a, J68d, J65a, J65b):
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.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.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).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.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:
channelmessenger.net and both store listings say "No phone number and no email address, at any point." Before anything above ships that sentence becomes: "No phone number or email is required. If you choose to be findable by one, it is verified once, never stored beside your identity, and you can switch it off." English governs; the six translations follow. This is a one-line site change and a store-listing edit; it is the gate.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:
ContactKeys exist, every message is sealed to them and discovery is never consulted, whatever numbers the card carries.J68a).J65a said it; the contact editor now enforces it: ticking Find me by on a number or email another profile already presents is refused with one line naming that profile. Numbers compare by digits (leading + kept), emails lowercased. A profile may still present several values of its own.Email verification is free (Resend's free tier or Netlify's email integration); only SMS costs.
##### J95. Verification: international from day one, email free, SMS capped. — Don
Don, 4 Sep: "what about international as we gonna need that of course" and "email verify should be free basically... set budget to $200/m for now for texts."
International is not an extra; it is the default. The verify endpoint takes E.164 only -- a leading + and 8 to 15 digits -- so a UK, Spanish or Australian number is the same shape as a US one and no code branches on country. Consequences that follow, and the honest costs:
| US / Canada | Most of Europe, LATAM, APAC | The expensive tail | |
|---|---|---|---|
| Telnyx SMS, per message | about $0.004-$0.01 | about $0.01-$0.06 | India, Nigeria, some Gulf carriers: $0.06-$0.20+ |
| What $200 a month buys | 20,000-50,000 | 3,000-20,000 | as few as 1,000 |
So the cap is counted in messages, not dollars (VERIFY_SMS_MONTHLY_CAP, default 20,000), because a dollar cap would be spent unpredictably fast by whichever countries turned up. Two guards go with it: five starts per value per hour, and the ability to refuse SMS while still offering email when the cap is reached.
Email is free and is the recommended path everywhere. Resend's free tier (3,000 a month) or Netlify's own email integration; no per-message cost, no country pricing, no carrier filtering, and no A2P registration. The app should offer email first and SMS second.
Two things about SMS abroad that must not surprise us later: a US 10DLC or toll-free sender cannot reliably deliver to many countries, so international traffic needs either an alphanumeric sender ID (fine in most of Europe, forbidden in the US) or local numbers; and some countries (India, Vietnam, Saudi) require template pre-registration. Start with email everywhere, SMS in the US and Canada, and add countries as testers appear in them.
##### J96. A verification is a fact about two profiles, it is clearable, and it can never transfer. — Don
Don, 4 Sep: "verification is really something that exists between two users and isn't a trait for a single person... when I say two persons I really mean two profiles... could the verify transfer in some way if we could know the 2nd profile was the same user?"
The first half is exactly how it is modelled, and now visibly so. numbersConfirmed and the provenance live on the contact row, which belongs to one profile's store. Two profiles on this phone that both know the same person have two rows and two independent answers. Nothing about verification is stored against a person or a device.
It is clearable, and that is deliberate rather than a testing convenience. iOS had Clear verification; Android now has the whole identity block -- the safety number, These numbers match, and Clear verification. A user may withdraw a claim they made: they are no longer sure, the peer changed phones, or they are testing. The badge derives from the flag, so clearing it reads as Not verified immediately.
The second half is a no, and the reason is the reason profiles exist. A verification cannot transfer from one profile to another, even between two profiles of the same person, because:
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.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.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:
J65b says so about a much smaller version of it).#### The shape that works
J40), so the link is key-agreement strong and needs no server. In person, as Don says.J84a): the profile sends its paired contacts a small "these are my devices" payload listing device public keys, re-sent when it changes. A sender then seals one envelope per device and deposits under each device's own token. Each device therefore has its own mailbox and there is no race.#### The costs, stated before anyone builds it
UNIT-ECONOMICS.md counts polling as the dominant cost, so devices multiply it exactly as profiles do; a cap (say four devices per profile) belongs in the first version.#### What it is not
It is not J20's "each device is its own identity" being reversed -- that stays true at the key level, which is the point. What changes is that several identities may present as one person to a contact, with the contact's app doing the fan-out.
##### J99. Platform order: phones first, then linked devices, then tablets properly, then macOS, then Windows. — Don, and the measurement behind the answer
Don, 4 Sep: "i'm thinking the phones should be nearly feature complete before we expand to create the macos and windows desktop apps unless you think that's easier than I do... and we need ipad and android tablet support as well if we don't already have it."
Agreed on phones first, and there is a hard dependency that settles the order anyway.
#### Where tablets actually stand today, measured rather than assumed
| Ships to it | Runs | Looks designed for it | |
|---|---|---|---|
| iPad | yes -- TARGETED_DEVICE_FAMILY "1,2", all four orientations | yes, verified 4 Sep on an iPad Pro 11-inch simulator | no |
| Android tablet / Chrome OS | yes -- the Play track already lists Phones, Tablets, Chrome OS, Android XR | expected (no tablet-only code), untested | no |
So tablet support exists and nobody has to do anything to keep it. What does not exist is a tablet layout: on the iPad the app is a phone stretched wide -- one narrow column of content with grey either side, the tab bar promoted to a segmented control at the top, and the wordmark pushed onto its own row. Usable, not embarrassing, not right.
#### Why the desktop cannot come first even if we wanted it to
A desktop app is blocked on J98. Without linked devices, a Mac build would be a separate identity -- a second contact your friends would have to pair with, with its own messages, which is not what anyone means by "the Mac app". Linked devices is therefore not an optional companion to desktop, it is the thing that makes desktop mean anything.
#### The order, and what each step costs
J91, is the gap).J98) -- the prerequisite for everything below.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:
widthIn(max: 480) habit already helps; the split view has to collapse and expand as the window changes, not at launch.CommandGroup/commands { } in SwiftUI): New Chat, New Contact, Check Now, Settings, and the profile switcher belong there, with the keyboard shortcuts a menu implies. This is the same code a Mac Catalyst build needs, which is another reason step 3 comes before step 4 -- it is not iPad work that gets thrown away.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.
#### 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:
| Menu | Items | Shortcut |
|---|---|---|
| File | New Chat · New Contact · Check Now | ⌘N · ⇧⌘N · ⌘R |
| Edit | the platform's standard editing items | -- |
| Profile | switch profile (list) · Manage Profiles | ⌘1…⌘9 |
| View | Messages · Contacts · Settings | ⌘⌥1…3 |
| Help | How Channel picks a path · Privacy | -- |
Android and iPhone have no menu bar, so those commands live where they already do -- and nothing may exist only in a menu. A menu is a second route to something reachable another way, never the only route, or the phones lose the feature.
#### What "the same" does not mean
UI-CONSISTENCY.md.Where this is enforced: UI-CONSISTENCY.md gets a section per screen with the three-pane behaviour and the menu table above, and any new platform starts by implementing that document rather than by looking at screenshots of the others.
##### J101. Discovery, as built -- and the one thing verification cannot stop. — Don
Don, 4 Sep: "if you check find me by for an email or phone that's when you have to do the verify for that item before we can make it live for discovery... maybe say unverified and not expose for discovery until verified" and *"otherwise someone could accept for a phone they don't own/control
#### Built and tested end to end on the emulators (4 Sep)
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.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.J68d): Accept or Ignore, and the contact that results reads Not verified, because nobody compared a number.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.
---
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.
---
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.
---
Don, 4 Sep 2026: "when you choose a phone contact to add to channel you should import all numbers and all emails ... for each item i had hit a verify button in which case we will attempt contact for the purpose of seeing if that phone or email or handle has been exposed by her - of course if she has no account or has not exposed any of them we change that verify button to No Profile and if we find it exists then that item should show the name of her profile ... so later if we choose her for an outgoing message if there is more than one profile linked we choose the one we want to contact", then "she can do the same of course with us or anyone else", then "we document well the degree of certainty because if an email or phone is exposed on the net it's because that user proved they controlled that account long enough to verify it".
Most of this is right and is being built. One part of it cannot be built as described, and it is the part that looks most innocuous.
### 1. Import everything, locally — yes
Adding a phone contact brings across every number and every address on the card, each an unchecked row in the contact editor. This does not touch the standing rule ("no saving or uploading of contacts is allowed"): nothing is uploaded, nothing is hashed and sent, nothing leaves. The user is looking at their own address book, in our app.
### 2. "No Profile" is a membership oracle, and it is the one thing J68c exists to prevent
An endpoint that answers "does this number have a Channel account, and what is the profile called" is a phone-number-to-name directory that anyone can download. There are about ten billion possible numbers. Checking them is not a hard attack or an expensive one — it is a loop. Whoever runs it ends up holding the list of every Channel user with a published number, each one paired with the name they chose. That list is exactly what a hostile government, a stalker, or a data broker wants, and we would have built it for them and served it over HTTPS.
This is why discovery is write-only (J68c, J91): you may leave an invitation at slot = H(value), and the deposit succeeds identically whether or not anybody is listening. Enumerating every number in the world yields nothing, because every answer is the same answer. The moment one request can return "no such user", that property is gone and every other privacy measure in the product — ECH, padded responses, rotating tokens, a relay that stores nothing — is protecting a database we published on purpose.
Don's own argument makes the oracle worse, not better. He is right that a published value carries real weight: it is there because somebody proved control of it during verification (J102 §3). But that means a scan would not return guesses — it would return verified name-to-number pairs. The higher the assurance of each row, the more valuable the harvested list. The verification work is what makes the oracle dangerous rather than what makes it safe.
### 3. What we build instead: the probe is an invitation
Everything Don wants from the button, except the negative answer, comes out of the existing write-only path. Pressing Reach on a row deposits a pairing invitation at that value's slot and starts listening, exactly as J91 already does. Three states, and the wording matters:
| State | Shown as | What it actually means |
|---|---|---|
| Never pressed | Reach | We have not tried. |
| Pressed, nothing back | Waiting | Either nobody holds that value, or they hold it and have not opened the app. We cannot tell, and neither can an attacker. |
| Answered | Lusmar (work) | Somebody polling that slot ran the handshake. The row now names their profile. |
The middle row is the whole design. "No Profile" and "Waiting" differ by one word and by the entire threat model. Waiting never resolves to "no". It ages out after seven days and offers to try again.
The cost to the user is real and worth stating: the answer is not instant, and it depends on the other person opening the app. That is the price of not shipping a directory, and it is the right trade.
### 4. One request on her side, not six
Probing four addresses and two numbers for one person must not put six separate requests in front of her. It does not have to: every one of those invitations carries the same identity key — ours. Her app groups inbound invitations by sender key and shows one request: "Don would like to connect. He reached you at lusmar@work, lusmar@home, and +1 555 0100." Accepting once links every value that reached her.
This also tells her something she should know and would otherwise not: which of her published values a stranger already has. That is a privacy feature falling out of a UI fix.
### 5. Several profiles for one person — yes, and it falls out for free
If two of her profiles answer from two different values, each answer is its own key agreement, so we end up with two linked profiles under one contact card, each labelled with the value that reached it. At send time, a contact with more than one linked profile asks which one — matching J93, that no two profiles on a device may publish the same value, so a value always names exactly one profile.
### 6. Symmetric, by construction
"she can do the same of course with us or anyone else" — she can, and nothing here is asymmetric. Both sides run the same code, both sides only ever deposit, and neither side can ask a question the other cannot ask back.
### 7. The certainty we claim, written down
Don asked that we document the degree of certainty. Precisely:
J102 §3). This is a genuine claim about the value.J101, J102 §4). Until the blind capability of J102 §4 exists, "she answered" means "somebody polling that slot answered".discovery / unverified, and says so on its face, until two people compare a safety number. Not a decoration — the tier is the mitigation.---
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:
POST /v1/invite?slot=… writes into an invites store (with keep/sticky from J102);GET /v1/invite?slot=…&exp=…&cap=…, plus a signature over slot ‖ exp ‖ timestamp by the device key named in the capability.The ordinary /v1/collect reads the mailbox store only and can never see the invites store, so a modified client cannot get at invitations through the unauthenticated door. Deposits stay open to everyone — anyone may send an invitation, which is the whole point.
Binding to a device key matters. Without it the capability is a bearer token: leak it once and whoever holds it can collect that number's invitations for a year. With it, a stolen capability is inert without the private key, and the timestamp in the signature stops replay.
What this achieves. A modified app can no longer publish a number it does not control and harvest invitations meant for the owner. To get a capability it must pass the SMS or email challenge for that value, which means controlling the value — which is the definition we wanted.
What it does not achieve, stated plainly: the relay operator can mint any capability, so the operator can still collect any slot. That was already true, is true of every server-mediated messenger, and is why the tier stays unverified until a safety number is compared. The safety number is the check that no server can forge.
### 3. Layer 2, later: unlinkable capabilities and no IP
To take the operator out of it as well:
J68c already earmarks this machinery for handles; it is one build serving both.J103 §5), so the relay never sees the client's address, and only means something if the gateway is run by a different party.Layer 1 is a week's work and closes the attack Don named. Layer 2 is the real prize and should not hold Layer 1 up.
### 4. Ordering
Layer 1 lands before discovery ships to users, because shipping the feature without it is shipping the impersonation hole. J102 §4(b) — invitations that survive collection so the owner sees the attempt — stays regardless: it is the detection half, and detection and prevention are not substitutes.
---
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.
J102 §4, J104 §7, J105 §2), why Reach says Waiting and never "No Profile" (J104 §3), what a relay does and what enabling it exposes, and what leaves the device and what does not.---
Don, 4 Sep 2026: "we're going to need group chats too - our medical group has a few - one for sharing birthdays among staff (family) one for all the providers and one that is physician only all on whatsapp".
Three real groups, overlapping membership, tens of people not thousands. That shape decides the design.
### 1. Pairwise fan-out first, not sender keys
A message to a group is sealed separately to each member, using the pairing this device already has with them, and deposited as N ordinary envelopes. There is no group key, no new crypto, no new wire format — a group message is a message with a group id on it.
The cost is N envelopes per send. At 50 members that is 50 deposits, which is nothing on R2 pricing (J103 §1) and well inside the mailbox limits. The benefit is that the entire security argument of the product carries over unchanged: every message is still end-to-end sealed to one identity key that this device verified, forward secrecy is per pair, and a compromised member cannot decrypt anything not addressed to them.
Sender keys (one encryption, keys distributed once) is the right answer at thousands of members and the wrong first answer here: it adds a key-distribution surface, a re-key on every membership change, and a new class of bug, to save cost we are not paying. Cap groups at 64 members for now and revisit if anyone hits it.
### 2. The server learns nothing about the group, and this is the strong claim
There is no group object on the relay. Membership, name, and history live only on members' devices. N envelopes to N rotating tokens are indistinguishable from N unrelated one-to-one messages — the relay cannot tell a group exists, let alone who is in it. That is better than WhatsApp, where the server holds group membership, and it is worth saying plainly in Help.
### 3. Membership needs everyone paired with everyone — introductions are the missing piece
Pairwise fan-out requires each member to hold keys for every other member. In a group of twenty that is not something people will do by hand.
So a group invitation carries the roster: each member's display name and identity key, signed by the member who sent it. Receiving it creates contacts by introduction rather than by pairing, and that deserves its own assurance tier:
| How the contact arose | Tier | Worth |
|---|---|---|
| Scanned or compared in person | Verified | Two people compared a safety number. |
| Introduced by a verified contact | Introduced | As good as the introducer, and no better. |
Reached by number or address (J104) | Not verified | Somebody polling that slot answered. |
Introduced is not Verified and must never render as it. A member who adds a stranger has introduced a stranger to everyone. Showing the introducer by name — "in this group, added by Lusmar" — is what makes that judgeable, and it is what the tier is for.
### 4. Membership changes are signed and replicated, never authoritative from one side
Add and remove are messages, signed by the member making the change, applied by each device to its own copy. Divergence is possible and is handled by showing it rather than hiding it: a member whose roster disagrees is flagged, not silently reconciled. Leaving is local and final; removal is a request every device honours for its own copy.
### 5. Profiles and groups
Groups belong to a profile, not a device (J65, J93). Don's own case makes the reason obvious: the physicians-only group belongs on a work profile, the staff birthday group may not. Membership of one group must never reveal membership of another, and two of his profiles in the same group is exactly the ambiguity J93 forbids — one profile per group per device.
### 6. What we do not claim, and it matters here
Clinicians in a group will discuss patients. Everything about the design helps — nothing on the relay, no cloud backup, disappearing messages, no analytics — but we must not claim HIPAA compliance, and Help must not imply it. A messenger is not a covered-entity compliance program, there is no Business Associate Agreement, and saying otherwise would be a legal claim we cannot support. Help states what the product does technically and leaves compliance to the practice. English is normative (standing rule).
---
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.
---
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.
.completeFileProtectionUntilFirstUserAuthentication. That is a deliberate choice recorded in the file -- full protection would make the store unreadable whenever the phone is locked, which breaks background delivery -- but it means the protection is the operating system's, not ours, and the bytes are cleartext underneath it..atomic and no protection class at all, which is an oversight rather than a decision. Fixed in this pass.writeText into filesDir: plain JSON, protected only by app-private storage and the platform's disk encryption. The identity secret is properly held in EncryptedSharedPreferences behind the Keystore; the messages are not.So Don's suspicion is right. A phone that is seized after first unlock, or imaged by a forensic tool, or rooted, gives up the message history. Sandboxing stops other apps; it does not stop that.
### 2. Encrypt the store under a hardware-held key
The store file becomes AES-256-GCM ciphertext under a key that lives in the Secure Enclave / Android Keystore and never leaves it. Same availability as now -- released after first unlock, so background delivery still works -- but the bytes on disk stop being readable at all without that hardware. A disk image, a stolen backup, or a copied file yields nothing.
That is the default. Above it, an opt-in setting for people who want more:
Lock messages when the app is closed. The store key requires user authentication, so history is unreadable while the app is not open. The cost is stated where it is offered: messages cannot arrive in the background while locked. Real security, real trade, user's choice.
### 3. The kill switch, and its honest limits
J98) or a backup (J110). That is what makes a panic wipe usable rather than catastrophic.### 4. Biometrics can be compelled. A code cannot. This changes the design
Don's observation is the sharpest thing in the message and it is well founded: a face or a thumb can be used on an unwilling person, and in several jurisdictions courts have treated biometrics differently from a memorised passcode for exactly that reason. A code can be withheld by saying nothing.
So:
J97) may be set to code-only, so Face ID cannot open it even though it opens the phone.LocalAuthentication tries biometrics first and gives no way to demand the device code alone, so an app-level code is the only way to offer a genuinely biometric-free gate. It is also what makes a duress code possible at all.### 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.
---
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.
J88, J89) is excluded from the archive, not merely re-timed. Restoring a message somebody believed had gone would break a promise made to the other person, who never consented to this device's backup settings. This is the constraint most products get wrong.J98).---
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:
J109. Each linked device must carry the same at-rest encryption, its own lock setting, and its own panic wipe. A profile is only as protected as its least protected device, and the device list is where a user can see that.---
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:
---
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.
---
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:
600123456 becomes +34 600 123 456, so a wrong country is obvious before anything is published.### 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.
---
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.
Work becomes Work 2, then Work 3 -- and the field is focused and selected so renaming is one action. Don's instruction exactly: a working name now, changed whenever they like, rather than a modal demanding one.J65);J102, J105) -- proof of control belongs to the profile that proved it;J93 forbids two profiles on one device presenting the same number, so copying them would create the exact collision that rule exists to prevent. The Find me by ticks come across as off.J92) are not copied because they are not per-profile; only the per-profile overrides are.---
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:
J92), default on. Global rather than per-profile: it is about the person using the phone, not about which identity they are wearing. Off in one tap for anyone who does not want them, and off means off everywhere.### 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.
| Where | Swipe left (trailing) | Swipe right (leading) |
|---|---|---|
| Conversation row | Delete (confirmed) and Archive | Mark unread |
| Contact row | Delete contact (confirmed) | Message |
| A message | Delete for me | React |
Two rules run through all of it:
---
+ beside the composer, and what we put behind itDon, 4 Sep 2026, with a WhatsApp screenshot: "Note the use of plus to the left of the text field, we should copy that with as many of those items as we can easily do." Their sheet holds Photos, Camera, Location, Contact, Document, Poll, Event and AI images.
Copying the shape is right and cheap: one + where the photo button is now, opening a grid. It stops the composer growing an icon per feature, and it is where users already look.
What goes in it, and why not all of it.
| Item | Us | Why |
|---|---|---|
| Photos | Yes, already built | The system picker, out of process; the app never holds a photo permission. |
| Camera | Yes | Same shape: capture and hand back one picture. Needs a FileProvider on Android for a full-size result rather than a thumbnail. |
| Document | Yes, later | The attachment transport already chunks (PROTOCOL.md payload type 6); what is missing is generic file support, since today it is images only. |
| Contact | Yes, later | The card format exists (J84a), but sending somebody else's card is a different thing from sending your own, and it hands a third party's details to a fourth. It gets a confirmation naming who is about to be shared. |
| Location | No, for now | It needs a location permission and turns a messenger into something that knows where you are. In a product whose claim is that it holds nothing about you, that deserves its own decision rather than arriving as the fifth icon in a grid. A one-off "send my current location" with no tracking and no history is the version worth considering. |
| Poll, Event | No | Real features, unrelated to anything this app does yet. |
| AI images | Never | J111: a generated image is a round trip to somebody else's model. It also puts an AI button inside the one screen where the product promises nothing leaves. |
The grid is labelled, like the screenshot: an unlabelled icon grid is a guessing game, and J115 §5's hints exist precisely because a feature nobody can find is a feature nobody has.
---
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
J92), as Don says: it belongs to the person and their account, not to a profile.J109's territory, and the app lock and at-rest encryption are what bound it. Help says this plainly rather than implying a key is harmless.---
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
J39's on-demand translation gains a much better engine for anyone who has connected one, and stays on-demand.### 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.
---
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
J68d) and, if she accepts, the pairing completes and the thread exists for both.### 3. What the user is told, and what stays true
"Nobody is told whether they are there, including you, until they answer." That is not a hedge, it is the property: a deposit succeeds identically whether or not the slot has an owner, which is what makes enumerating numbers useless. The Waiting state of J104 §3 never resolves to "no", and this sheet says so in the same words.
---
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.
CLLocationManager.heading / SensorManager rotation vector). Standing still, a compass still knows which way the phone is pointed, which is Don's "you don't always know which way I'm facing if I'm stationary".### 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.
---
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
J113/the import already reads -- with Import to Channel underneath. Look first, decide second.J112's rule that creating something puts you on it), where the fields are editable.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.### 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.
---
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.
J92) for entering the app, and per profile (J97) for entering a protected profile.### 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.
---
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:
Discovery.normalize already produced it; nothing used it.### 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:
.oneTimeCode. Nobody reads digits off a notification and retypes them. Android can do the same through the SMS Retriever API, which needs the app's hash in the message body and no permissions -- worth adding.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.
---
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:
J105 does for numbers, so only the holder polls the handle's slot.J104, reaching one leaves an invitation and waits; nothing answers "is this handle taken" to a stranger.Which means handles land after J105 §3's blind issuance, not before. Building them on a plain table would be the one thing this product has consistently refused, and doing it for convenience would undo the rest.
---
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:
J86a) -- which opens a conversation rather than sending on its own, so the user sees and sends the message themselves.The app never sends any of these silently. It composes; the person taps send in the other app. That keeps it out of the business of messaging on someone's behalf through a channel we do not control.
### 3. Carrying a pairing code in the invitation, and what that is worth
Don's "might even offer the option to give a public key" is the valuable half. The invitation can carry the sender's PairingPayload -- the same 66-character code the pairing screen shows -- so when the recipient installs and pastes it, they are paired immediately, with no need to guess which of her addresses is published. It removes the whole problem he describes.
The tier is codeRemote, and it must say so. A code sent over SMS or WhatsApp is exactly J40's "code sent to you": anyone who can rewrite that message can substitute their own code, and the two people have not compared a safety number. So the resulting contact is Not verified until they do -- better than nothing, weaker than meeting, and labelled honestly rather than presented as though a key exchange over WhatsApp were the same as one done face to face.
### 4. Why this matters more than it looks
It is the answer to the cold-start problem. Until now the only ways to reach somebody were pairing in person or discovery, and discovery only works if they already have the app and published the value you happen to know. This closes the loop with the thing everybody actually has: an existing way to message that person.
---
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:
J86a), for numbers marked as being on those apps.The app composes and never sends. The person taps send in the other app, so we are never messaging on somebody's behalf through a channel we do not control.
This rests on Don's own point: "this requires that the person sending this message knows that she has control of both those numbers." It does. That is the sender's own knowledge, exactly as the app marks are (J121 §2), and it is why the tier below is what it is.
### 3. The wording, near enough Don's
> Don Elton would like to connect on Channel. > > Paste this code into your Channel app to start a conversation: > > 05X524…B6N04
During the beta there is no link, so finding the app is left to the reader -- which is fine while the only recipients are testers. Later the same message carries a store link, and later still a universal link that opens the app with the code already filled in.
### 4. The tier, and why it is not "verified"
Don calls it "a verified contact", and it is worth being exact: this is J40's codeRemote -- a code sent to you over a channel we do not control. Anyone who can rewrite that text message can substitute their own code, and the two people have not compared a safety number. So the contact is real, keyed, and able to carry messages, and it shows as Not verified until they compare numbers in person or on a call where each knows the other's voice.
That is not pedantry. The whole tier system exists so that the one case where somebody did meet in person is distinguishable from the case where they trusted a text message.
---
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.
---
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:
lifetimeHours = 24 instead of the week a discovery invitation gets. A late redeemer finds nobody there either.The relay never learns the code existed. That is the right place for the rule: the sender is the only party who needs to enforce it.
Why shorter than a discovery invitation: an invitation left at a slot cannot be read by anyone, so there is nothing to steal. A texted code is a bearer token sitting in two message histories, so the window in which a stranger could redeem it should be short.
### 3. No absolute time in the message
Don's reasoning is exactly right and worth keeping: we do not know their time zone, nor when they will open it, and a wrongly-rendered clock time is worse than none. So the message says "only for 24 hours from when you received this message" -- relative to something their phone already displays beside it.
### 4. What is still to do: the code should not carry the long-term key
Don's "represents the key but is not the final key" has a part not yet built. Today the payload carries the long-term identity public key, so anyone who reads the message learns a key that appears in every pairing this device ever does -- a linkability leak, even though the key is public and grants nothing on its own.
The fix is an ephemeral pairing key: the code carries a throwaway public key, and the long-term identity is authenticated inside the encrypted handshake. That is a change to mesh-core-rs and the handshake transcript, so it is real work and is recorded here rather than done in passing.
---
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.
---
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:
google-services.json, plus the FCM dependency.notifyId -> token row (register.mts), give paired contacts that notifyId so senders can attach it (J46: the sender supplies it and the relay stores no map from mailbox to device), and handle the wake by polling.### 3. The setting, once it exists -- and Don's "secret agent" profile is the right shape
### 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:
notifyId. A contact of one profile and a contact of another then hold different opaque ids, and comparing them reveals nothing. This is a requirement, not an optimisation: reusing one notifyId across profiles would let two colluding contacts prove the profiles share a device.notifyId -> pushToken and must know the real token to call APNs at all, so two profiles using push on one device appear as two ids with the same token. There is no way around it: the wake has to reach a device, and the device has one address.So Don's model holds against the internet and against other users, and does not hold against the relay operator, for exactly the profiles that both use push.
Which turns the per-profile switch from a curiosity into the thing that answers it: a profile that must not be linkable to your others should decline push and poll. That is the "secret agent" profile with a concrete reason attached, and it is why the switch has to exist rather than being a device-wide yes or no.
J44 already records: a targeted wake means this device asked to be reachable at a stable push token, which is a durable identifier the content-free heartbeat avoids. That is the actual privacy cost, and it is why the polling-only option exists rather than being a curiosity.---
Don, 4 Sep 2026: "She added a number and we sent a message to verify but it went via SMS, but she only uses that number with WhatsApp, so when we verify we need to ask by what channel."
J123 settled code versus link and assumed the code always arrives by SMS. That assumption is wrong for a large share of the world: plenty of numbers are WhatsApp-only in practice, and some -- landlines, certain VoIP numbers -- cannot receive a text at all. Sending SMS and waiting is then a dead end with no error, because the send succeeds and nothing arrives.
### 1. Ask, rather than assume
The verify sheet gains a channel choice before it sends anything, listing only what can actually work for that value:
| Channel | Status | Why |
|---|---|---|
| Text message | built | The default where it works, and iOS autofills the code (J123). |
| Voice call | next, and cheap | Telnyx reads the digits aloud. This is the answer for a landline or an SMS-blocked number, and it needs no new provider -- the same account and number. |
| later, and not cheap | Meta's Business API, business verification, an approved template and per-message pricing. Worth doing because Don's case is common, but it is a procurement exercise, not an afternoon. | |
| built | Already the better path where an address exists (J102). |
### 2. Why voice comes first
It closes most of the gap for a fraction of the effort. A number that cannot take a text can almost always take a call, and Telnyx does both from the account already configured. WhatsApp only helps people who have WhatsApp; voice helps everyone.
### 3. A phone with no SIM changes the answer
Don, 4 Sep 2026: "Her phone — this one — has no SIM and is WiFi only, hence needs WhatsApp to receive message."
That rules out both SMS and the voice call §2 recommended, since neither exists without cellular service. It also exposes something the table above glossed over: verifying a number by WhatsApp proves control of the WhatsApp account, not of the number. A WhatsApp registration outlives the SIM, so if that number is later reissued, the person holding the new SIM controls SMS while she still controls WhatsApp. Two different people, two different proofs, one value.
And for Don's actual goal it is beside the point. Verification exists so a value can be published for discovery -- so that somebody who knows only her number can reach her. He is not in that position: he already has a way to reach her. So the route is J126's invitation, sent over WhatsApp, with no verification of anything:
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.
---
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
J131, with the channel stored beside the proof as above. This is the one that answers Don's case properly.---
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:
J132 §3), so the record says what actually happened.### 5. What else Meta offers that is worth taking
J111 already rules out routing message content through anyone's model.### 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.
---
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:
+34 number gets Spanish.Two details that matter more than the voice does:
interpret-as="digits", or "123456" is spoken as "one hundred twenty-three thousand..." in most languages.### 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
J133, needs the Meta app.---
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
48HA7NAS59, created for TitleVitals but team-scoped to all topics, so it signs for Channel without minting anything new. Team 34LMDLB33Y.com.channelmessenger.app App ID. That was the real blocker, and it is now on. Enabling it invalidates existing provisioning profiles; automatic signing regenerates them on the next build.secrets/ (git-ignored) and set on the relay as APNS_KEY_P8, base64: a PEM begins with five dashes that every CLI reads as an option, and its newlines do not survive a web form.### 4. Two traps in the sending code, both silent
fetch is HTTP/1.1 only. It answers with what looks like a network fault. Hence node:http2 and a hand-built request.R‖S signature; Node signs EC keys as DER by default. APNs rejects a DER signature as a bad token — the same error it gives for an expired key. dsaEncoding: "ieee-p1363" is the fix.Also: apns-push-type: background with apns-priority: 5 is the only combination Apple accepts for a payload carrying nothing but content-available. At priority 10 it is discarded.
### 5. What the wake carries, which is nothing
{"aps":{"content-available":1}} on iOS, a data-only message with an empty data map on Android. No content, no sender, no conversation id, and no indication of whether there is mail — heartbeat fires on a schedule either way, so Apple and Google see a rhythm, not a delivery log (J10, J41).
wake() never throws and never blocks a deposit. The envelope is in the mailbox before it is called, and the recipient's next poll collects it regardless. Push is an optimisation over polling, not the delivery path — which is exactly why a profile can decline it and lose only promptness.
### 6. Still needed
POST /v1/register, hand paired contacts the notifyId (J46), poll on wake. Plus J130's global and per-profile switches.com.channelmessenger.app is currently registered inside the TitleVitals Firebase project (titlevitals-app), so an FCM service-account key for it would give the Channel relay access to TitleVitals. For the app whose entire pitch is separation, Channel should have its own Firebase project.---
Don, 4 Sep 2026: "She hit the button. There was no error message, but the text message never arrived, so check that flow." And then, correctly: "When there is an error, you should have code to identify that there was an error, because sometimes it will be for some other reason, like a malformed number."
### 1. What actually happened, demonstrated rather than guessed
sendSMS returns res.ok — *and a 200 from Telnyx means accepted for delivery, not delivered.* Reproduced against production: +15005550001, a US number that can receive nothing, returned {"ok": true} and HTTP 200. Telnyx took it and the message went nowhere.
So the app was told the truth as it knew it. There was no error to show, because at the only moment we look, there was no error. The loss happens afterwards, at the carrier, and we never look again.
### 2. Three defects, not one
a. We could not tell an accepted message from a delivered one. Fixed by /v1/delivery, a webhook for Telnyx's delivery receipts. It stores a tally per day per status and nothing else — no number, no message id, no code. A delivery receipt names its recipient, and A6 rules out keeping anything that reconstructs who was contacted, so the number is read in order to be discarded. The carrier's reason is kept, because that is about the route and not the person: "unregistered" is the difference between a bug and a wrong number. Thirty-five days, expired opportunistically.
b. Every send failure said the same wrong thing. A 502 mapped to unavailable, whose copy is "Verification is not switched on yet." When the real cause is a typo, that sentence sends someone to look at the app instead of at what they typed. The relay now classifies:
badNumber — the provider says that is not a textable destination.unreachable — well formed, but no route carries it: a landline, or a country that refuses our sender.provider — our fault, and the fallback, because blaming ourselves is the right way to be wrong.This is not an oracle. PROTOCOL.md §0 forbids errors that distinguish whether a mailbox exists. These distinguish only the shape and reachability of a value the caller supplied and already knows, and say nothing about whether anyone is registered at it.
c. We contradicted ourselves about the messaging profile. When from was a number Telnyx owns, the payload also named a profile — and discoverMessagingProfile takes whichever profile the account happens to list first, which need not be the one that number belongs to. Telnyx resolves the profile from the number itself, so the extra field could only ever disagree with it. Now sent only for an alphanumeric sender, which genuinely needs it.
### 3. What is still unknown, and it needs Don
Why US messages are accepted and dropped. The overwhelmingly likely answer is 10DLC: a new long code sending application traffic to US numbers is filtered by US carriers until its brand and campaign are registered, and the API accepts throughout. Confirming it means reading the message's delivery record in the Telnyx portal, and registering the webhook above against the messaging profile — both need a Telnyx login, which is Don's to give.
Until then the honest position is that SMS to US numbers is unproven, and the email route — which is verified working end to end — is the one to test onboarding with.
---
Don, 4 Sep 2026: "The number display on her contact does not show the country code, but it should even when she's not editing, because the number was saved supposedly with a country code." Then: "Problem the display and not how the number was saved or how the number was sent to the texting agency?"
### 1. It is the saved value. The display is faithful
Everything that uses a number runs it through Discovery.normalize first — the discovery slot, the verification code, the proof it is filed under. Only save() skipped that step and stored the raw text. So a number typed the ordinary national way was kept without its country code, and shown that way forever after. It was never "saved with a country code"; it only looked that way from the inside, because every consumer added one on the fly.
Both platforms now canonicalise to E.164 on save — but only when the result is genuinely reachable. A half-typed number, or one entered before the device knows its own calling code, is kept exactly as written. Silently rewriting something we cannot resolve is worse than leaving it.
### 2. The number sent to Telnyx was correct, so this did not cause the missing text
VerifyValueView sends Discovery.normalize(value), never the raw field, and the relay normalises again. The SMS went to an E.164 number. The display defect and the undelivered code are separate faults.
### 3. But the country in that E.164 number is a guess, and that is not separate
Discovery.deviceCallingCode comes from Locale.current.region — the phone's region setting. For a national number it is simply prepended. So if the device's region and the number's country disagree, the app resolves to a real-looking number in the wrong country, deposits nothing at the right slot, and texts a stranger or nobody. J113 §3 anticipated this; what it did not have was any way for the user to notice.
Fixing §1 is what makes it noticeable: once the saved value is the resolved E.164, the contact shows +1 … and a wrong guess is visible before anyone waits for a text that cannot arrive.
This is now a leading candidate for Lusmar's undelivered code, alongside 10DLC — her handset has no SIM, so its region is whatever iOS was set to, not where her number lives.
### 4. What is not fixed, and must not be mistaken for fixed
None of J136 or this entry makes an SMS arrive. J136 makes a failure legible; this makes a wrong country visible. The delivery question is still open and still needs the Telnyx portal.
---
Don, 4 Sep 2026: "Are we saving the number as bare and thus showing what we saved, or is it just the display is stripping the country code?"
Saved bare. Checked rather than assumed:
save() passed phones straight to store.updateContact with no canonicalisation.ContactDetailView printed Text(field.value) — the stored string, verbatim.dropFirst(2) in the codebase parse the CallingCodes table and convert an international 00 prefix; neither touches a stored value.The +1 visible while editing was ContactEditView's live preview — Discovery.normalize(field.value) rendered as a caption under the field. Computed on the fly, never written down, which is exactly why it disappeared on leaving the form.
### What that means for the fix
J137 corrects what is stored, and only from now on. Every contact saved before it still holds a bare number, and asking someone to re-save each one by hand is not a fix. So Discovery.forDisplay corrects what is shown: the resolved E.164 where we can resolve it, the raw text where we cannot. Applied at every site that shows a number to a person — the contact card, the verify sheet's title and both of its sentences, the invite routes — on both platforms.
### Telnyx expects E.164, and the +1 is never stripped
Don asked whether to strip it for US numbers. Checked against Telnyx's own specification: to is "a +E.164 formatted phone number", their example being +18665550001 — the +, the country code, then the ten national digits. Removing the +1 produces Invalid Phone Number Format and the message is never sent.
So the rule is one-directional: always append, never remove. The country picker already rewrote the field to E.164 when touched; J137 now applies the same resolution at save whether it was touched or not.
---
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.
---
Don, 4 Sep 2026, photographing the top of a conversation: "No pink LGBT buttons please."
The circles were contact avatars, and the colour came from Double(abs(seed.hashValue) % 360) / 360 — the whole colour wheel. Magenta and pink came up as readily as anything else, so the one rule this app's palette has (AppTheme: one hue, garnet, and nothing that lightens or drifts into pink) was being ignored in the single place that generated colour at runtime.
Replaced with a curated ten: 12, 28, 42, 88, 128, 158, 186, 205, 222, 238 — earth, moss, teal, steel. The magenta arc, roughly 260–350°, does not appear. Ten is ample to tell a contact list apart, and every one sits quietly under white initials.
### The second bug in the same line
String.hashValue is seeded randomly per process, so "the same person keeps the same colour" was quietly false — every contact was recoloured on each launch. Now FNV-1a, which is stable, which was the entire requirement. Android's hashCode() was already stable but had the same full-wheel problem; both ContactEditScreen and ProfileSheet now share the constrained palette.
---
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.
---
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.
---
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.
---
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.
---
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
| Screen | Block? | What the capture would carry |
|---|---|---|
| Conversation | Yes | The messages. This is the reason the feature exists. |
| Photo / attachment viewer | Yes | The picture itself, and view-once has no meaning without it. |
| Pairing — my QR / code | Yes | A pairing code is single-use and rotates; a picture of it is a pairing attempt anyone can start. |
| Pairing — confirmation code | Yes | Twelve digits that, photographed beside the other phone's, prove nothing about this exchange. |
| Contact card | Yes | Numbers, emails, the safety number, the photo — the whole identity, on one screen. WhatsApp blocks exactly this one. |
| Messages list | No | Names and previews. Worth a lock, not a shield; blocking it blocks the screenshot every bug report starts with. |
| Contacts list | No | Names. Same reasoning. |
| Settings, Help, Profile sheet, Requests | No | Nothing a stranger could use. |
| Verify sheet | No | The number being verified, which the user just typed themselves. |
| App switcher snapshot | Yes, always | Whatever was on screen when the app was left — the one capture the user never chose to take. |
Default: on, for the screens marked yes. The setting stays one switch — Block screenshots — because a per-screen menu is J67's prose problem in another form, and nobody will curate it.
### 3. What the capture shows instead of black
J70 §1: "you control what appears in the capture, not merely that it is blank." The shielded screens get a second layer outside the secure canvas carrying one line — Channel blocks screenshots of this screen — so the image that leaves the phone explains itself, and the J144 alert becomes a courtesy rather than the only clue.
### 4. Why not keep the whole app shielded and be done
Because it makes the feature look like a fault. The first thing every tester does is screenshot a problem to send it in; if that comes back blank, they conclude the app is broken, not private, and they stop trusting what it shows them — which is the exact opposite of what a privacy control is for. The cost of the per-screen version is one wrapper per sensitive screen and an honest line of text. That is cheap.
### 5. Awaiting Don
The table is a recommendation. It is his call whether the Messages and Contacts lists should also be shielded — the argument for is that names alone are sensitive to some users; the argument against is §4.
---
Don, 4 Sep 2026: "Have Grok review this whole QR code process too." Its verdict is in PAIRING-REVIEW-2.md, verbatim. Nothing in it was taken on trust; each claim was read against the code, and this is what held.
Verdict on the core: sound. The Rust handshake — STS-style X25519 + Ed25519, a fixed 182-byte transcript with a compile-time length assertion, reflection and contributory-DH checks, generation bounding, zeroisation on drop — drew no findings. Safety numbers are stable either way round. Stale flights are scoped by the per-attempt nonce. J141–J143 are confirmed fixed.
### 1. Medium-High, confirmed and fixed: the public slot let a stranger end the attempt
Anyone who saw the QR can compute the inbound slot and deposit a well-formed first flight. handle answered every such flight — so two scanners, or a scanner and a stranger, spawned two responder sessions racing for one confirm. Worse, a first flight whose sender never sent flight 3 hit the timeout and set .failed, which ended the attempt: park one offer in the slot and walk away, and the honest scanner arrives to a code already marked failed.
Fixed on both platforms: one offer is negotiated at a time (iOS gains a negotiating guard; Android's linear coroutine already had that property), and a timed-out offer is forgotten rather than fatal — its half-built session freed, the listener back to waiting, the code's own expiry the only thing that ends the wait.
What is not fixable locally, and is by design: a stranger who completes the exchange before the friend does. The first flight is unauthenticated on purpose (handshake.rs says so), and the spoken comparison is the root of trust — the stranger's code cannot match the number on the friend's phone. The inbound sheet now says the one sentence that makes that true in practice: "Add them only if the person with you sees this same number."
### 2. Medium, confirmed and accepted: clock skew beyond the blackout
J143's six-second blackout is local to each phone. Two phones more than six seconds apart can still straddle a bucket. Phones keep NTP time and skew is normally under a second, so this is rare — but it is real, and the fix is a wire change (carry the scanner's clock in flight 1 so the displayer can measure the skew and warn). Deferred; recorded here so it is not forgotten.
### 3. Medium, on the time bucket itself: a fair point, not a flaw
Grok is right that the ephemeral secret alone kills precomputation and the bucket only bounds post-exchange grinding, buying a shorter spoken code at the price of the clock dependency. That is the trade handshake.rs documents. Twelve rotating digits versus roughly fourteen fixed ones — the shorter code read aloud was chosen on purpose. Left as is.
### 4. Low, confirmed and fixed: "Numbers Match" with no numbers
On the identity-keys-only path there is no confirmation code, and the button still read Numbers Match — Add Contact: a lie the user was asked to tell. Both platforms now say Add Without Verifying there.
### 5. Low, confirmed and fixed: the handoff decided by name
saveContact asked whether the peer had paired by looking up a contact by display name. Two contacts can share a name and either can be renamed; both made it answer about the wrong person. Now by public key on both platforms — Android's onAdded callback carries the key beside the name.
### 6. What this cost
Under a nickel of Grok, and one Medium-High that a human reviewer would have needed the field test to find. The two Lows are the kind that survive because nobody reads a button label twice.
---
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:
J16's one hard migration — and it is now decided before v1, which is the whole point of J20's warning.J20 said this too; it survives the change of model).J94's rule extends across linked devices: an exposed value belongs to a profile, and the profile now spans devices, so it is still one slot, one identity. Nothing changes for discovery.Cost: this is the largest remaining protocol item. Its place in the order is after push and the verification routes Don is testing now, and before anything that would make identity harder to change. It is decided, not scheduled.
---
Don, 4 Sep 2026: "Lusmar is receiving my messages via internet but I'm not receiving anything from her." Then: "But the safety numbers show unmatched on my screen."
Those two facts are one fact.
### 1. What the relay poller actually does
ServerTransport.collectOnce derives its inbound mailbox tokens from each contact's keys — myDestinationTokens(for: contact.keys, epoch:) — and polls every one. The keys are the pairing. So if his card for her holds the keys of an earlier pairing while her card for him holds the latest, she deposits to a mailbox derived from the new keys and he polls one derived from the old. He never sees her. And the safety number on his card, computed from the old key, cannot match hers.
The other direction working is the same fault seen from her side: his messages are sealed with the old keys, and she can read them because her phone still has the old pairing too — J142 reached her only in 2.027, so she almost certainly has two cards for him, and it is the older one that lights up.
### 2. Why the re-pair did not fix it
completePairing(contactID:) — the attach that J142 routes a from-inside-a-contact pairing to — began:
``swift guard let index = …, !contacts[index].isPaired else { return } ``
Any re-pair of an already-paired card was a silent no-op. Android's attachPairing had the same rule (if (existing.keys != null) return false), with the reason written beside it: so a double tap cannot invalidate a working conversation. Reasonable — and it meant that the one re-pair that mattered, after her identity had changed, left his card exactly as it was. Both phones reported success. Neither was wrong to.
### 3. The rule now
Refuse a re-pair only when it brings the same key — that is the double tap. A different key was just confirmed aloud by two people who did it on purpose, and it replaces the old one. Both platforms.
### 4. Tonight, without waiting for a build
Delete his Lusmar card and pair once more from scratch; the new card takes the new keys. Or she deletes her older card for him — but his is the one polling the wrong mailbox, so his is the one to reset.
### 5. What the last four entries add up to
J141 (why numbers disagree), J142 (the displaying half made a second card), J143 (the code rotated mid-comparison), and this — a re-pair that could not replace keys — are one afternoon's field test against a pairing flow that had only ever been exercised between two emulators that never changed identity. Every one of them is a state that a fresh pair of phones never enters.
---
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).
---
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:
ContactCardWire(name, stock, jpeg) — a name and a picture. Phones and emails are never in it, verified or not, on either platform. A contact who wants your number asks you.collectDiscovery skips any value without a collection capability, and a capability exists only once the code came back (J105). An unverified number's slot is never polled, so an invitation left there reaches nobody.J101). Publishing is a consequence of proof, not a request for it.The two numbers Don saw under her name on his phone were on a card he created — his own contact entry, never anything her phone sent. The distinction is the one J147 §2 draws: the rule governs your own card, and a contact is somebody else's guess about you.
---
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.
---
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.
---
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).
---
J153 builtDon, 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.
attachOrAdd on both stores: a completed pairing with nowhere to attach lands on a contact of the same name (case-insensitive, trimmed) whose key differs, replacing its keys under J148's rule. Used at all four pairing sites and by accepted requests. The accepted risk — two people with one name on one phone — is caught by the safety-number comparison, which is the check people actually run.J36's "no letters there" for the bubble: Don, comparing the two phones side by side, asked for the same mark. The tick colour still carries the path for outbound.ScreenCaptureShield is one view with an enabled property; the hosted content is re-parented between the secure canvas and the plain container instead of the root view changing type.[0x02][key][nonce][len][name ≤ 24 bytes], sent for invitations only (QR stays v1); both decoders accept both. The row reads "Someone calling themselves Lusmar", the name field is prefilled and optional, and requests are one per sender key.pendingAcceptances persisted; the exchange runs behind the list, resumes at launch, expires with the invitation; the Requests screen shows Waiting for them with Cancel.rememberReach used by New message too, so the two cannot drift again.---
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.
J135 built the relay side; this is the phone side, both platforms.
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.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.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.aps-environment is development in the entitlements file and Xcode rewrites it to production on archive.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.
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.
+ 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.
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.
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.
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.
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.
mode: periodic. A scheduled relay function (wake-sweep.mts, /15 *) sends every such id one content-free push whether or not anything is waiting; the phone polls exactly as it would on its own timer. On iOS that timer never fires for a closed app -- this does. Because the pushes are on a clock, their timing says nothing about any message, to Apple, Google, the relay's own logs, or anyone on the path.mode: event: deposit.mts wakes the id when an envelope arrives, and the sweep leaves it alone. Faster, at the cost that wake timing now matches mail timing.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.
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.
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.recoverAside(): with no conversations.json but an unreadable-* sibling, the newest one is decoded and adopted if it decodes now.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).
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.
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:
J163 §3).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.
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.
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.
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.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.RequestsView in a sheet; a sheet cannot fail to present.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:
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.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.The J169 Reach line stays: it still tells whether this phone can be found, which matters for the first contact and for relaying.
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.
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.
Don, 5 Sep 2026, after sending a location both ways: "They both appear as Google Maps links ... you should have two options really, one is what sort of location you send and the second is what kind of location you display when you receive one ... both links send latitude longitude, therefore you could receive a Google Maps location and display it as an Apple Maps location."
Settings › Location, on both platforms:
Send my location writes.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.
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."
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.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.### Noted for later, not built (Don, 5 Sep)
J161).Don, 5 Sep 2026: "duplicate the android keyboard feature that allows you to view the current contents of the clipboard below the text field, and you can hit the button and it will paste ... it's actually showing two clipboard fields."
Android: a one-line row above the composer showing the current clip -- the text, or "Photo" for a copied picture -- and a tap pastes it into the draft or stages the picture like a picked one. The row is the app's, so every keyboard gets it. The Samsung keyboard's second entry is the keyboard's own history; Android shows an app only the current clip, and only while in focus, so there is one row. The paste icon inside the field is gone: one mechanism.
iOS cannot do the row. Reading the pasteboard to show it raises the "Allow Paste?" banner on every read unless the user flips a Settings switch, and there is no history. UIPasteControl is Apple's answer: it lights when the clipboard holds text or a picture, and the tap is the consent, so nothing is read until asked and no banner appears. Replaces the J115 §6 button, and adds what that button lacked: a copied picture pastes as a photo.
The loop. TODO.md (pending) and DOCS-TODO.md (shipped, undocumented) now exist, seeded from J155–J175, with the rule in CLAUDE.md. Don: "we're adding a lot of features lately and it would be easy to forget some of these capabilities and leave them out of the docs."
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.
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:
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.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.
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
session replaces the previous position; the thread does not grow by one line per fix ("doesn't have to be visible in the usual way").J120 §2) -- with both phones, the bearing arrow and distance when the other is within about a mile, and the live position moving.### Sender
J120 §1); one tap to stop. Updates every 60 s while the app is open, and one fix whenever the app wakes to fetch; background continuous tracking is not attempted in this pass.canOpenURL / package query), plus the in-app map.### Order of work
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
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.
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.
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:
loc:1;… token (above), sent as text. Send As is gone from Settings; Open With lists the map apps found on the phone (canOpenURL / package queries; the manifest and LSApplicationQueriesSchemes name Google Maps and Waze).+ › Location › Send My Location, Share Live for 15 Minutes / 1 Hour / 8 Hours, Stop Sharing. A fix every 60 s while the app is open; ends by itself. Sessions are keyed by thread on both platforms, so two of three chats sharing at once stay separate.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.
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.
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.
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.
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".
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 @).
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):
J174 note (the clipboard as AI input); a staged picture in the composer is the other half.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.
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.
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.
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.
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."
J161 migrates into the first account. Every account counts requests and characters, so a hosted account (J186) can be capped; own-key accounts are not capped.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).
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:
J104). Nothing forwards, because forwarding would let an old handle keep finding you after you gave it up.J183 addendum).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.
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.
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.
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.
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.
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.
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.
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."
IdentityConfidence): Verified -- keys exchanged and the two people compared the confirmation numbers; Not verified -- a real key agreement, nobody compared numbers; Weak -- paired by a method with no real secret; and no badge at all when there are no keys. The badge says the word, so it reads without colour.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.
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.
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.