Repository navigation
Inbox mobile: the relay as the app's fallback path (M6) - #1801
Merged
Merged
Conversation
backnotprop
force-pushed
the
feat/mobile-m6-relay
branch
from
October 8, 2026 19:33
605b1a0 to
5f4c243
Compare
backnotprop
force-pushed
the
feat/mobile-m6-relay
branch
2 times, most recently
from
October 9, 2026 01:59
504a025 to
17feb1d
Compare
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
force-pushed
the
feat/mobile-m6-relay
branch
from
October 9, 2026 02:59
17feb1d to
90688d7
Compare
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-minutesgoes 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
SourceSession.settleHeldreads 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 inPendingSend.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.willPresentnow keys on the summary the extension leaves (PushNotification.read), or onefor 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.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.proof.tschecks 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) andDoorAnswer;exchangesends it directly (the bearer token over HTTP,httpsand the pinned session on the Wi-Fi) or as a relay command;senddecodes it.InboxPath(wifi,tailnet,relay);probe()(healthwith 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'sfp, nothing else (contract section 3)..../Relay.swift:RelayClientgainspost(any door request as a command sealed underU),items(after:)(opened underK, read only when the sealedtypeisrecordorresult),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.performruns 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 (afterpast the last seq seen) reads the list and open threads again, GETs only whileinbox_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;syncRelaystells 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):NSLocalNetworkUsageDescriptionfor 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,/askwithnopush, and the agents' waits paused while the Inbox is stopped (a tool call starts a stopped Inbox).adr/implementation/inbox-mobile.mdsection 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) andapps/ios/README.md: the paths and the proof.Record sections made real
9.2-wifi,9.2-tailnet,9.2-relay,9.2-waiting,9.2-relay-off, besiderecord-9.2-*).aftergap checks, ack, commands up with their keys,too_largedrawn, 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) andRelayTransportTests(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 itsSourceClientchange is the idempotency key only.RelayTransportTests(alone:--only PlannotatorUITests/RelayTransportTests):lanandfp(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.plannotator inbox mcp): the row comes through the relay, the thread is read through it, Yes is picked and sent through it, the agent'swait_for_replygets it; no Send reached the door directly and the relay counted the commands.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_replyget "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.
too_largeand the thread's read through the relay answers 413result_too_large; the thread draws "Too large to show here" once.items?after=0with the bearer derived from the computer's copy of the pairing secret).RelayRecordsTests(Swift Testing, in the samexcodebuild 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 git . --config .gitleaks.toml): no leaks..semgrep.yml) on the changed Swift and TS files: 0 findings; twonosemgrep: plannotator.web.request-to-networkinscripts/proof.tswith 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 testinapps/ios/PlannotatorKit: 19 tests pass.xcodebuild build-for-testing: no errors or warnings.What it leaves out
addresses.lanandfingerprintare not taken (contract section 3: the fingerprint comes from the computer's screen); 9.2 reads "Not set up" for it.Deviations
sendtakes aDoorRequestand is an instance method, so a call is the same request on every path. M2 and M4 callSelf.send(request(...)); at their rebase that becomessend(request(...)).Follow-ups (not in this PR)
too_largemessage is absent while the computer is asleep and draws only through the thread read's 413 (review I3, named in the contract).On main with M2 and M3
attachments,view) now go throughsendandhtmlAssetthroughexchange, 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:ReplyBodycarries M2'sfeedbackandannotation_ids.SourceMenu.subtitle) reads the session's relay state; its Try Again chooses the path again.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.sendis now an instance method taking aDoorRequest, andInboxClient.sessionis the direct path's alone. M4's guide read (Guides.swift) and M2's attachment reads now callsend, so they ride whichever path is in use.SourceClient(M7) now takes the caller's idempotency key onseen,pick,resolve,deleteandsetDecisionRecording(one key per intent across a change of path, section 6).WorkspacesSourceaccepts 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
healthwaits 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 ownretry: 2000).