Skip to content

Inbox mobile: the relay as the app's fallback path (M6) - #1801

Merged
backnotprop merged 1 commit into
mainfrom
feat/mobile-m6-relay
Oct 9, 2026
Merged

backnotprop merged 1 commit into
mainfrom
feat/mobile-m6-relay

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

The iPhone app now reaches a paired computer by the three paths of 9.2 in order: the same Wi-Fi (the QR's listener, its certificate pinned), the tailnet, then the Plannotator relay, on by default from pairing as the fallback; 9.2's switch turns it off and on. When neither direct path answers, the app reads and answers the Inbox through the relay end to end encrypted: store lines come down into the threads it keeps, the list and threads are read as commands while the computer is online, and picks and Sends go up sealed with the door's idempotency keys. A Send (or a lock-screen answer) that reaches a computer that is asleep or stopped waits at the relay, and the thread says "Sent. Waiting for your computer" until it lands, once.

On main (after M5, M3, M2 and M4 merged; one squashed commit, the review's fixes listed below).

The iPhone CI job's timeout-minutes goes from 45 to 75 (.github/workflows/ios.yml): the suite now runs the M1 to M6 flows on one runner, and macOS minutes are free on a public repo; no per-test wait is raised.

The review's blockers (REVIEW-PLANNOTATOR-MOBILE-M6-2026-10-08), fixed

  • B1, a refused held Send kept waiting off the relay. Back on the Wi-Fi or the tailnet, SourceSession.settleHeld reads the thread (a landed Send settles by its key), then reads the relay once for its held Sends' result items, which settle each one as sent or refused. With the relay switch off no result comes down, so each held Send (kept whole in PendingSend.body) is posted again directly with its own key; the door answers from its log, or applies it now and answers the relay's copy from the log later. A refusal reads in the person's words (notSent): "Not sent. That question changed on your computer. Pick again.", never the door's text with a question key. Proof: step 9b below.
  • B2, a push on a phone never made the open app read again. willPresent now keys on the summary the extension leaves (PushNotification.read), or on e for a push the extension did not dress. Proof: step 7 now delivers the push dressed, as the extension leaves it on a phone; the simulator never runs the extension, so the extension itself under a real push stays a device proof.
  • B3, the lock-screen answer could run past its background time. It asks the Wi-Fi and the tailnet at once with probe (3 s, InboxClient.firstReachable, shared with the session), sends on the one that answered with a 5 s wait, else posts to the relay with a 5 s wait: 3 s plus 5 s away from home, well inside the about 30 s iOS gives an action. The same key throughout. The proof on a real locked phone waits on the owner's test iPhone.
  • I1, the replay step replayed a GET. It now posts every command the phone sent again, the pick and the Send among them (the script cannot open them to pick one); the store cursor does not move and the thread keeps one reply.
  • Also taken: I2 (the dead gap flag dropped; any record batch re-reads the list and open threads while online, which covers a dropped item), I3 and I5 (named in the contract's M6 paragraph), I4 (part of B1), I7 (proof.ts checks the data dir on every spawn of the binary).

What changed

  • apps/ios/PlannotatorKit/Sources/PlannotatorKit/InboxClient.swift: one client per path. DoorRequest (method, door path, query, JSON body) and DoorAnswer; exchange sends it directly (the bearer token over HTTP, https and the pinned session on the Wi-Fi) or as a relay command; send decodes it. InboxPath (wifi, tailnet, relay); probe() (health with a 3 s wait); InboxError.queued; every command takes its idempotency key from the caller (section 6: one key per intent across a change of path). pair(at:pin:...) redeems over the pinned listener.
  • .../Pinning.swift (new): the Wi-Fi listener's check, the leaf certificate's SHA-256 against the QR's fp, nothing else (contract section 3).
  • .../Relay.swift: RelayClient gains post (any door request as a command sealed under U), items(after:) (opened under K, read only when the sealed type is record or result), ack(through:|cursor:), setCarriage; RelayChannel (an actor: one read at a time, each batch handed to the app and then acknowledged; a command reads back its own result).
  • .../RelayRecords.swift (new): a down item's record decoded, and applied to a thread (a message line replaces its snapshot and keeps its questions; a question line goes into its message); a row for a thread the list does not show yet.
  • .../Models.swift: InboxMessage.projectId, idempotencyKey, threadName. .../EventStream.swift: the stream dials on the client's own session (pinned on the Wi-Fi).
  • apps/ios/Plannotator/App/SourceSession.swift: the path. connect() asks the Wi-Fi and the tailnet at once and takes the first in that order, else the relay while it carries this phone, else unreachable; it runs on return to the front, pull to refresh, Try Again and a push in front. A direct path follows the event stream; when it drops, one reconnect with the cursor after the stream's 2 s, which chooses the path again. perform runs a call on the path in use and, when a direct path gets no answer, once more on the newly chosen one with the same key. On the relay: items applied in seq order, a gap (after past the last seq seen) reads the list and open threads again, GETs only while inbox_online, records drawn while the computer is asleep. Pending Sends, sendProblems, the too-large marks. Reading directly, it acks the relay by store cursor.
  • apps/ios/Plannotator/App/AppModel.swift: Source.lan (address and pin from the QR), Source.tailnet, directClients; SourceState (the store seq, the last relay item, the switch, the carriage the relay holds, per source); PendingSend; pairing by link tries the Wi-Fi first, then the tailnet; the relay switch (relayOn, on unless the person turned it off; setRelay; syncRelays tells the relay a change it did not hear); removal tries the Wi-Fi then the tailnet.
  • apps/ios/Plannotator/App/Notifications.swift: the lock-screen answer takes the Wi-Fi, the tailnet, then the relay (only while its switch is on) and leaves a pending Send in its thread; a push in front makes the app read again. PlannotatorApp.swift: the switch synced on return to the front; the pairing confirmation lists each address it will contact, in order.
  • apps/ios/Plannotator/Settings/SettingsTab.swift: 9.2 whole (Same Wi-Fi, Tailnet, Plannotator relay with its switch, the footer, This phone with Paired and Notifications, Remove); the status line names the path in use; 9.1's rows say the path.
  • apps/ios/Plannotator/Thread/ThreadScreen.swift, ReplyBar.swift, Inbox/InboxTab.swift, Inbox/InboxList.swift: "Sent. Waiting for your computer", "Too large to show here", a refused held Send's words in the reply bar, the Inbox subtitle for the relay states, pull to refresh choosing the path again.
  • apps/ios/project.yml (and the regenerated project, Info.plist): NSLocalNetworkUsageDescription for the Wi-Fi path, in its own M6 block.
  • apps/ios/PlannotatorUITests/RelayTransportTests.swift (new), apps/ios/PlannotatorKit/Tests/PlannotatorKitTests/RelayRecordsTests.swift (new).
  • apps/ios/scripts/proof.ts (an M6 block): a proxy in front of the relay (the Inbox's socket and the phone's calls; it keeps what the phone posts and counts its reads), the door proxy cutting the event stream when the tailnet goes down and counting Sends, control routes /lan, /pair-link-lan, /hits, /replay-command, /inbox-stop, /inbox-start, /too-large, /relay-items, /ask with nopush, and the agents' waits paused while the Inbox is stopped (a tool call starts a stopped Inbox).
  • adr/implementation/inbox-mobile.md section 4: "Written in by M6", behaviour only, no frozen shape changed (the path order and when it is chosen, GETs only while online, the read-back, ack by cursor; the switch on from pairing, Allow notifications registering the push token only, as ruled on this PR).
  • AGENTS.md (CLAUDE.md) and apps/ios/README.md: the paths and the proof.

Record sections made real

  • 9.2 whole, light and dark: the three rows, the switch, the footer, This phone, Remove; the line under the name names the path in use with its tick (9.2-wifi, 9.2-tailnet, 9.2-relay, 9.2-waiting, 9.2-relay-off, beside record-9.2-*).
  • Decision 9 (all three paths, the relay as the fallback) and pick 5 of the iPhone record (the relay on by default, used only as the fallback; ruled on this PR: on from pairing, notifications govern pushes only).
  • OWNER-ITEMS item 26: a lock-screen answer, or a Send, that cannot reach the computer waits at the relay and the thread says "Sent. Waiting for your computer".
  • Contract section 4: down items after the cursor, after gap checks, ack, commands up with their keys, too_large drawn, the carriage switch through 7.35.

Proofs

Frames light and dark and the recording M6-relay-waiting.mp4: research/operational/mobile-proofs/M6-2026-10-08/ in the Workspaces meta repo.

bun apps/ios/scripts/proof.ts --binary .local/plannotator (the compiled binary per the README; iPhone 17 simulator, iOS 26.5), the whole app proof on this head (17feb1d9c, on main): the PlannotatorKit tests (21), KeychainMigrationTests (M5), AttachmentProofTests (M2), DecisionsProofTests (M3), ProofTests (M1), RelayPushTests (M5) and RelayTransportTests (M6, 491 s) all pass. M7's Workspaces proof (scripts/workspaces-proof.ts) runs against staging with a session the lane does not hold and was not run; M7's code is compiled and its SourceClient change is the idempotency key only.

RelayTransportTests (alone: --only PlannotatorUITests/RelayTransportTests):

  1. The Inbox's Wi-Fi listener on. A QR link whose pin is not the listener's certificate, Wi-Fi only: the confirmation shows the listener's address, Pair answers "Not paired", the computer lists no phone.
  2. The QR link with lan and fp (the listener dialled at loopback) and the tailnet's stand-in: the confirmation lists both addresses in order; pairing goes over the Wi-Fi, pinned; the redemption carries the relay.
  3. 9.2: "Connected over Wi-Fi" with the Wi-Fi ticked; the relay switch on from pairing, and still on after Allow notifications.
  4. The Wi-Fi listener off on the computer, pull to refresh: the tailnet ticked, Wi-Fi "Not on it now". The tailnet down too: "Through the relay" in the Inbox, "Connected through the relay" in 9.2.
  5. An agent asks (plannotator inbox mcp): the row comes through the relay, the thread is read through it, Yes is picked and sent through it, the agent's wait_for_reply gets it; no Send reached the door directly and the relay counted the commands.
  6. Every command the phone posted (the pick and the Send among them) posted again to the relay byte for byte after they were applied: the relay holds each again, the Inbox answers each from the door's log, the store cursor does not move, the thread still has one reply of the person's.
  7. On the relay mid-draft, words in the composer; the tailnet comes back and a push, dressed as the extension leaves it on a phone, makes the app read again: it moves to the tailnet, the words are still there, Send goes to the door directly and reaches the agent.
  8. On the tailnet mid-draft, a pick and a note being typed; the tailnet drops (its event stream cut): the app moves to the relay, the note is still there, Send goes up through the relay with the note and reaches the agent.
  9. The Inbox stopped (control/stop): a pick and a Send on a thread read before: "Sent. Waiting for your computer" in the thread, "Waiting for your computer" in the Inbox and 9.2; a lock-screen Yes on another question (the exact push Apple received, simctl push): "“Yes” sent. Waiting for your computer." The Inbox started: both agents' wait_for_reply get "Yes", each thread holds one reply; a pull and the thread shows it sent, the waiting line gone.
    9b. A held Send the computer refuses: the Inbox stopped, Yes picked and sent on the phone (waiting), the Inbox started with its relay socket cut off, the question picked twice on the computer, the socket let through (the held pick and Send are applied and refused); the tailnet back and a pull: the thread reads "Not sent. That question changed on your computer. Pick again." with no question key, no longer waiting, and shows the computer's pick; no reply written.
  10. A 25 MiB reply written on the computer: its store line goes down as too_large and the thread's read through the relay answers 413 result_too_large; the thread draws "Too large to show here" once.
  11. The relay switch off in 9.2: "Not reachable right now. The relay is off."; an agent asks: no push goes out, the row does not arrive, the Inbox says "Not reachable, relay off", and the relay holds no items for the phone (read through the relay's own items?after=0 with the bearer derived from the computer's copy of the pairing secret).

RelayRecordsTests (Swift Testing, in the same xcodebuild test): a down item opens only as a record or a result (a push or another version refused; the placeholder has no record); records applied to a thread as a thread read shows them; a row added once.

The M1 (ProofTests) and M5 (RelayPushTests) flows run unchanged on this branch and pass.

Still needs the test iPhone: camera pairing; the Wi-Fi path on a real network (the Local Network prompt, the pinned listener at the Mac's LAN address); a real APNs push waking the relay read; the lock-screen answer under Face ID.

Gates

  • bun run typecheck: green.
  • Gitleaks on merge-base..HEAD (gitleaks git . --config .gitleaks.toml): no leaks.
  • Semgrep 1.172.0 (.semgrep.yml) on the changed Swift and TS files: 0 findings; two nosemgrep: plannotator.web.request-to-network in scripts/proof.ts with their reason on the line (the harness proxies forward only to this run's own relay and Inbox origins; the door proxy's line was already flagged on the base).
  • swift test in apps/ios/PlannotatorKit: 19 tests pass. xcodebuild build-for-testing: no errors or warnings.

What it leaves out

  • A computer whose mailbox id changes (R1 review item I7).
  • Bonjour discovery in 1.3 and following a Wi-Fi address that moves: the address and pin come from the QR; a new address needs the code scanned again.
  • The Wi-Fi path for a phone paired by typed digits: the redemption's addresses.lan and fingerprint are not taken (contract section 3: the fingerprint comes from the computer's screen); 9.2 reads "Not set up" for it.
  • Polling: on the relay the app reads on return to the front, a push, and the person's pull or Try Again; no timer. A Send waiting at the relay shows as landed at the next read.
  • The Workspaces source (M7), TestFlight (M8).

Deviations

  1. Not drawn: "Sent. Waiting for your computer" in the thread (owner item 26's words), a clock line under the messages with the words that were sent, in place of the delivery line while it waits.
  2. Not drawn: "Too large to show here", a filled card with "It opens over the Wi-Fi or the tailnet." The brief's "Fetch on Wi-Fi or tailnet" affordance is left out: the record has no button style inside a message, and a direct path re-reads it by itself when it answers.
  3. Not drawn: the words for each state. The Inbox subtitle "Through the relay", "Waiting for your computer", "Not reachable, relay off"; 9.2's line "Connected over Wi-Fi", "Connected through the relay", "Waiting for your computer", "Not reachable right now. The relay is off."; a row without an address "Not set up"; the relay row "Not available for this computer yet" when the pairing answer carried no relay.
  4. The event stream reconnects once, not forever. M1 retried every 2 s while the computer was away; now one reconnect with the cursor, then the path is chosen again and nothing polls (the brief's rule).
  5. While the computer is asleep, a thread a record brought shows in the list under Waiting on you (it asks something) or New since you looked, until the next list read replaces it.
  6. A pick the relay holds for an offline computer moves the question's revision on by one locally, as the Inbox will when it applies it, so the next pick and the Send line up behind it.
  7. PlannotatorKit's send takes a DoorRequest and is an instance method, so a call is the same request on every path. M2 and M4 call Self.send(request(...)); at their rebase that becomes send(request(...)).
  8. The proof turns on the Inbox's Wi-Fi listener, which listens on every interface and publishes its Bonjour record on the Mac's network for the run, as P2's proof does.

Follow-ups (not in this PR)

  • A held pick, resolve, delete or decision switch that the computer refuses has no words of its own; the next read of the thread shows the truth (review I5, named in the contract).
  • In the app, a direct Send on a path that hangs waits the session's 20 s before moving to the relay (review I8); the lock-screen answer already uses the short probe.
  • A too_large message is absent while the computer is asleep and draws only through the thread read's 413 (review I3, named in the contract).
  • 9.2's header sits about 60 pt lower than the render (M1's List, review nit).
  • Device proofs on the test iPhone: camera pairing, the Wi-Fi path at a real LAN address with the Local Network prompt, a real push dressed by the extension waking the relay read, the lock-screen answer under Face ID away from home.

On main with M2 and M3

  • M2's attachment reads (attachments, view) now go through send and htmlAsset through exchange, so attachments and HTML assets ride whichever path is in use, the relay included (a result's bytes and type). Annotations ride a held Send whole: ReplyBody carries M2's feedback and annotation_ids.
  • M3's subtitle (now SourceMenu.subtitle) reads the session's relay state; its Try Again chooses the path again.
  • CI fixes found on the way: 9.2 is taller than the screen when a row wraps, so every proof scrolls to Remove this source before tapping it (Reveal.swift); M6's flow registers M5's stand-in APNs token on a runner whose simulator gets none, straight at the relay; the phone acks the relay by cursor once per connect instead of after every read (a mailbox answers one request at a time, and a push waited behind the acks on a slow runner); the push wait in the harness is 60 s.

M4 on main

InboxClient.send is now an instance method taking a DoorRequest, and InboxClient.session is the direct path's alone. M4's guide read (Guides.swift) and M2's attachment reads now call send, so they ride whichever path is in use.

SourceClient (M7) now takes the caller's idempotency key on seen, pick, resolve, delete and setDecisionRecording (one key per intent across a change of path, section 6). WorkspacesSource accepts it and keeps its own keys (pickKey), so Workspaces behaves as M7 built it. With M7 on the base, the session is the M7 session with M6's paths for an Inbox source; a Workspaces source has one path, as before, and like the Inbox's stream it reconnects once after a drop, then waits for the next return to the front, pull or push.

Limits

None on people. Waits a path needs: a direct path's health waits 3 s; a command reads back its result for about 15 s (reads at 0.25 s doubling to 4 s), then counts as held; the event stream's one reconnect comes 2 s after a drop (the stream's own retry: 2000).

@backnotprop
backnotprop force-pushed the feat/mobile-m6-relay branch from 605b1a0 to 5f4c243 Compare October 8, 2026 19:33
@backnotprop
backnotprop changed the base branch from feat/mobile-m5-notify to main October 8, 2026 21:30
@backnotprop
backnotprop force-pushed the feat/mobile-m6-relay branch 2 times, most recently from 504a025 to 17feb1d Compare October 9, 2026 01:59
9.2 whole: the Wi-Fi listener (from the QR's lan and fp, its certificate
pinned), the tailnet, then the Plannotator relay with its switch, on from
pairing; the app says which path it uses. Through the relay the app reads
the items after its last one, applies their store lines to the threads it
keeps, reads the list and open threads as GET commands while the computer is
online, and sends its commands sealed under the up key with the door's
idempotency keys. A Send held for an offline computer reads "Sent. Waiting
for your computer" until it lands or is refused, on any path; the
lock-screen answer takes the same paths within its background time. The
review's three blockers are fixed (a held Send settles on any path, a dressed
push makes the app read, the lock-screen answer fits its time).
@backnotprop
backnotprop force-pushed the feat/mobile-m6-relay branch from 17feb1d to 90688d7 Compare October 9, 2026 02:59
@backnotprop
backnotprop merged commit 545c7af into main Oct 9, 2026
29 checks passed
@backnotprop
backnotprop deleted the feat/mobile-m6-relay branch October 9, 2026 04:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant