Files
Paul HittandClaude Opus 5 15b9d5b44a Say what is true, without dating it
Comments and docs kept dating themselves: when a constraint was set,
when a pair was verified, what a limit was before some day, which day a
subtree was split out. A reader wants the current state, and the date
goes stale faster than the sentence around it.

Kept, because they are data rather than narration: ISO timestamps in
test fixtures, the certificate expiry in project.yml, which an operator
acts on, and docs/BUGS-2026-09-01.md, whose name is the record.

⚠️ The first pass collapsed indentation. A whitespace rule meant for
prose matched YAML list items and markdown continuation lines, and
project.yml stopped parsing: "did not find expected key: - path:
Sources". The tests still passed, because they ran against the project
generated before the break, which is exactly how this would reach
somebody else. Reverted and redone without touching leading whitespace,
then xcodegen was run to prove the file parses.

764 XCTest and 75 Swift Testing pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 09:27:20 -04:00

5.4 KiB
Raw Permalink Blame History

Delivery without Google or Apple in the middle

Constraint set by the owner: no third party on the delivery path. That rules out FCM and rules out APNs. It does not rule out fast delivery, but it does rule out one thing completely, and that is worth stating before anything else.


The one thing this constraint costs

On iOS, a closed app cannot be woken without APNs. There is no alternative.

iOS suspends a backgrounded process and tears down its sockets. A long-lived connection, a NATS client, a foreground service, none of these exist on iOS. Apple provides exactly one wake-up mechanism for a suspended app and it is APNs.

So "no Apple in the middle" means, precisely: on iPhone, messages arrive when the user opens the app. Not late, immediately on open, but not while it is closed. That is an OS decision, not something the relay can engineer around.

The honest options are:

  • Accept it. Say so plainly in the app and on the site: "Flamenet does not use Apple's push service. Messages arrive when you open the app." For a product whose pitch is that no third party is involved, that sentence is an asset rather than an apology.
  • Use APNs anyway for a content-free wake-up. Apple learns that a device has mail and when, not from whom, not what. That is a smaller leak than FCM on Android (Google correlates far more), but it is still a third party.

Android has no such limit. A foreground service can hold a connection with nobody in between. Cost: a permanent "Flamenet is running" notification, real battery use, and aggressive OEM background-killers (Xiaomi, Huawei, Samsung) that will sever it anyway.


What actually fixes "I had to hit refresh", and needs no third party

Today E2EInbox polls every 5 seconds while the app is open (pollInterval: 5_000_000_000). That is both slow and wasteful. Replace it with long-polling, and both get better at once:

GET /e2e/poll?after=<cursor>       # server holds the request open ~30–60s,
                                   # answers the moment something lands
5s polling long-poll
latency up to 5s ~immediate
requests per device ~12/min ~1–2/min
battery a wake every 5s one idle socket

This is strictly better on every axis and involves nobody. It is the single highest-value change here, it is a relay + client change only, and it makes the app feel like it pushes whenever it is open, which covers the whole iOS story and most of the Android one.

Held requests need care: a timeout below the proxy's idle timeout (Traefik and Cloudflare both cut long connections), a cursor so nothing is missed between requests, and a cap on concurrent held requests per device.

SSE or a WebSocket are the same idea with a persistent connection instead of a re-issued request. Prefer long-poll first: it survives proxies and mobile network transitions with far less code.


Where NATS fits, and where it does not

NATS does not deliver to phones. It is server-side plumbing. A phone still needs its own live connection, so NATS changes nothing about the iOS constraint above. Adding it as "the push server" would be solving the wrong half.

What it does solve is fan-out behind the long-poll. With many held requests, something has to notice a new message and wake exactly the right ones. Three ways, in ascending cost:

  1. Poll Postgres from each held request. Simplest, and fine at this scale, a held request checking once a second is still 5× less DB load than the current client polling, for far better latency.
  2. Postgres LISTEN/NOTIFY. The send issues NOTIFY user_42; held requests wake instantly. No new infrastructure at all: you already run Postgres. Notifications are not durable: a listener that is not connected misses them, so the cursor query on reconnect stays load-bearing.
  3. NATS. Proper pub/sub, durable with JetStream, scales across relays and fits the federation work already in the tree. It is also another service to run, monitor, secure and back up on a box that has had one intrusion and a volume-prune incident.

Recommendation: 2, not 3, today. LISTEN/NOTIFY gets the same felt result with zero new moving parts. Reach for NATS when there are enough concurrent connections that one Postgres cannot hold them, or when relay-to-relay fan-out needs it, not before.


Sequencing

  1. Long-poll /e2e/poll with a cursor, plus the client switch from the 5s loop. Biggest win, no third party, no new infrastructure.
  2. LISTEN/NOTIFY behind it so held requests wake instantly instead of polling the database.
  3. Android foreground service holding the long-poll while backgrounded, opt-in, with the persistent notification explained honestly.
  4. iOS: decide between "no background delivery, and we say so" and "content-free APNs". Nothing else exists.
  5. NATS only if and when 1–3 stop scaling.

Steps 1 and 2 together are the small piece of work that removes the refresh button. Step 3 is a real but contained piece. Step 4 is a product decision, not an engineering one.


What none of this changes

  • The crypto, the ratchet, and the wire format are untouched.
  • NotificationManager already draws the banner; only the wake-up changes.
  • The relay still holds ciphertext it cannot read, so any notification it triggers is content-free by construction, the device decrypts and writes the real banner locally.