19 KiB
Nucleic — Mac ↔ iPhone Sync Protocol (v0)
How the iPhone (thin client) talks to the Mac (host). The host owns all state — repos,
processes, transcripts. The phone subscribes, renders, answers approvals, and (later) sends
input. The wire protocol is a projection of the backend layer: it ships AgentEvents
and the approval round-trip from BACKEND_PROTOCOL.md across a secure
channel.
Status: design draft. The application message set is the stable contract. The two
transports (LAN now, Cloudflare relay later) sit behind one SecureChannel so the message
layer is transport-agnostic.
1. Invariants & goals
- Host is the single authority. The phone never holds canonical state; it mirrors a subscription. Conflicts (e.g. two phones answering one approval) are resolved by the host.
- End-to-end encrypted, always — even on LAN. We do not trust the local network or the relay. Plaintext exists only on the two paired devices. The relay forwards opaque frames.
- One message set, two transports. LAN and relay differ only in how bytes move; the
encrypted
Frames and the application messages inside them are identical. - Resumable & idempotent. Every subscription is driven by the monotonic
seqfrom the backend protocol. Reconnect → re-handshake → re-subscribesinceSeq→ catch up. No message is required exactly once; consumers dedupe on(sessionID, seq)— within one revert epoch. A revert/undo truncates the transcript and resets the seq counter, so post-revert turns reuse dropped seqs with different content;SessionSummary.revertEpoch(a per-session monotonic revert count, also stamped on the transcript header and carried ontranscriptReverted/transcriptFetchComplete) is what scopes the dedupe. A device holding events under a different epoch than the owner advertises must drop and refetch them — every seq-gated merge below assumes epoch equality (the Mac mirror coordinator and the phone cache both heal on mismatch; a nil epoch means "predates the field": legacy behavior, tip-regression heals only). - Capability-scoped per device. A paired device is granted a scope (
view/approve/control). v1 iPhone =approve. The host enforces; the protocol carries it.
2. Layer cake
┌─────────────────────────────────────────────┐
│ Application messages (§5) ClientMsg/HostMsg │ ← projection of AgentEvent + approvals
├─────────────────────────────────────────────┤
│ SecureChannel (Noise XX/IK, §4) │ ← E2EE, mutual device auth, framing
├─────────────────────────────────────────────┤
│ Transport (§3): LAN (Network.framework) │ Relay (WS via Cloudflare DO, later) │
└─────────────────────────────────────────────┘
Encryption and authentication live in SecureChannel, above the transport, so the relay is
a dumb pipe and LAN gets the same guarantees. Swapping transports never touches §4/§5.
3. Transports
3.1 LAN (v1)
- Host advertises
_nucleic._tcpvia Bonjour (NWListener+NWTxtRecordcarrying host identity-key fingerprint + protocol version). - Client browses, resolves, opens an
NWConnection(TCP). Then theSecureChannelhandshake (§4) runs over it. TLS is not relied on for secrecy — Noise provides E2EE and mutual auth; the TCP layer is just bytes. - Works only when co-located. No server to run. This is the entire v1 networking story.
3.2 Relay (later, Cloudflare)
- A Worker upgrades a WebSocket and routes it to a Durable Object keyed by
roomID(derived from the pairing group / host identity). The DO holds the host's and clients' sockets and forwards opaqueFrames between them; it also tracks presence. - The relay cannot decrypt — the same Noise session runs end-to-end through it. The DO
sees only
roomID, frame sizes, and timing. - Relay admission: a short-lived
relayToken(issued at pairing, rotatable) gates who may occupy a room, so strangers can't squat or flood. It authorizes routing, not content. - Selection: when Relay is enabled, the client connects there first to establish a dependable session immediately. It then checks LAN and switches when the authenticated peer is local; otherwise it attempts the §3.3 STUN/direct upgrade and remains on Relay if punching fails. Without Relay admission, LAN and tailnet remain the ordinary direct candidates. The host registers with the relay whenever reachable.
3.3 Direct upgrade (Covalence direct)
The relay is a metered pipe, so a relay session upgrades in the background to a direct peer-to-peer path when the network allows — "Covalence" is the whole internet path (direct preferred, relay fallback), and the relay only brokers the first seconds of a session.
- Relay-first, then upgrade. The session connects and completes the Noise handshake over
the relay exactly as §3.2. Once live, both ends (when both advertised
WireCapabilities.canDirectConnect/WireClientCapabilities.canDirectConnect, the session rides the relay, and theNUCLEIC_DIRECTkill switch is off) run a hole punch in the background and migrate the frames to the direct path when it's proven. - Signaling inside Noise. Candidates (STUN-reflexive + local + global IPv6), NAT class,
and a per-attempt secret token are exchanged as
ClientMsg.directOffer/.directSelectandHostMsg.directAnswer/.directGo/.directDecline— over the established E2EE channel, so the relay never sees an address. STUN reflexive addresses come from public STUN servers (NUCLEIC_STUN_SERVERS, default Cloudflare + Google); two servers classify the NAT so a symmetric↔symmetric pair skips punching. - Punch + transport. Both sides UDP-hole-punch each other's candidates (
PunchEngine); the punch token, exchanged only inside Noise, authenticates the path. The validated, live socket then carries a reliable ordered byte stream — RUDP (RUDPFrameChannel: go-back-N window, cumulative ACKs, keepalives, address-latching) — wrapped as aFrameChannel. Noise still rides on top, so the direct path is E2EE like every transport; RUDP is plaintext-below-Noise and needs no TLS. (An earlier cut used Network.framework QUIC over a close-punch-socket-then-rebind handoff; RUDP replaced it — pure POSIX so Linuxnucleicdgets a direct path too, no port-handoff race, no TLS identity to manage.) - One Noise session across two paths.
MultipathFrameChannelwraps the relay channel and is a pure passthrough until armed (so a never-upgrading session is byte-identical). It keeps ONE active outbound path per direction, flipped by an HMAC-authenticatedSWITCHmarker (key = HKDF of the Noise handshake hash, so the relay can't forge it), sequences direct-leg frames with a retransmit buffer, and on direct-path failure runs a losslessRESYNCexchange over the still-open relay leg — refilling exactly the frames the peer hadn't processed, in order, with no re-handshake. The relay WebSocket stays open and idle after the upgrade (Durable Object hibernation makes it ≈free) for instant failback. - Scope. Only relay sessions upgrade (LAN/tailnet are already free and direct). The status
chip reads
Connected · Directwhile frames bypass the relay, and reverts on failback.
4. Security: pairing & SecureChannel
4.1 Identity
Each device generates a long-term identity keypair (X25519 for the Noise handshake, plus an Ed25519 signing key) in the Secure Enclave / Keychain. Public keys are exchanged once, at pairing, and pinned thereafter (trust-on-first-use, with the QR as the secure out-of-band channel).
4.2 Pairing (one time)
- Mac shows a QR containing: host identity public key, a one-time pairing secret, LAN
connection hint, and (later) a relay
roomID+ bootstrap token. - iPhone scans → has the host's public key over a trusted OOB channel.
- Devices run an authenticated handshake (Noise XX, the pairing secret mixed in as a PSK to bind the exchange to this QR and stop MITM). On success each stores the other's pinned identity key and a derived long-term shared secret.
- Host records the device in its paired-devices table with a
scope(defaultapprove) and a human label. The user can revoke a device anytime (drops the pin → future handshakes fail).
4.3 Session handshake (every connect)
Reconnecting uses Noise IK (client already knows the host's static key), giving mutual
auth + forward secrecy without re-pairing. Output: fresh per-session symmetric keys. All
subsequent Frames are AEAD-encrypted with a per-direction nonce counter.
4.4 Frame
Frame = length-prefixed AEAD ciphertext
plaintext(Frame) = { seqNonce: UInt64, msg: HostMsg | ClientMsg } // CBOR-encoded
Replay/reorder protection comes from the AEAD nonce counter; the transport guarantees in-order delivery (TCP / WS), so a gap means a dropped connection → reconnect + resync.
5. Application messages
CBOR-encoded (compact for the event-heavy path; AgentEvent is already Codable). Two
enums, both Sendable. Identifiers (SessionID, ApprovalID, Decision, AgentEvent)
are exactly the backend-protocol types.
5.1 Client → Host
enum ClientMsg: Codable {
case hello(Hello) // proto version, device id, scope claim
case listSessions
case subscribe(Subscribe) // sessionID + sinceSeq (+ verbosity)
case unsubscribe(SessionID)
case approvalRespond(ApprovalID, Decision)
case sendInput(SessionID, AgentInput) // scope ≥ approve; queued per backend rules
case interrupt(SessionID) // scope ≥ control
case fetchDiff(SessionID) // full worktree patch on demand (read-only; gate
// on WireCapabilities.canFetchDiff)
case ping
// reserved for scope=control / later: startSession, mergeSession, discardSession …
}
struct Subscribe: Codable {
let sessionID: SessionID
let sinceSeq: UInt64? // nil → host sends a snapshot + tail, not full history
let verbosity: Verbosity // .statusOnly | .coalesced | .full
}
enum Verbosity: String, Codable { case statusOnly, coalesced, full }
5.2 Host → Client
enum HostMsg: Codable {
case welcome(Welcome) // accepted scope, host info, capabilities
case sessionList([SessionSummary])
case snapshot(SessionSnapshot) // status + metadata + recent events + cursor
case events(SessionID, [AgentEvent]) // ordered batch; carries seq range
case approvalRequested(ApprovalRequest) // pushed to all subscribers
case approvalResolved(ApprovalResolved) // dismiss on other clients (first wins)
case sessionUpdated(SessionSummary) // status/diff-stat changes
case sessionDiff(WireSessionDiff) // fetchDiff's answer: files + capped unified patch
case error(WireError)
case pong
}
// `sessionList` is not only the `listSessions` answer: the host also PUSHES it unsolicited
// after a session is deleted (a deletion has no per-session "updated" shape). Clients treat
// any `sessionList` as a wholesale replacement of their mirrored list — the same handling
// they already do on connect — so a removed row disappears without waiting for a reconnect.
// The dashboard snapshot also rides account-level projections for the phone's home
// surfaces: `usage` (subscription quota windows — the Mac's gauges) and `statusFeeds`
// (provider incident feeds — the Mac's status pill), plus per-todo `triage`. All are
// decodeIfPresent-tolerant, so old/new host–phone pairs interoperate (§9).
struct SessionSummary: Codable {
let sessionID: SessionID
let projectName: String
let backend: BackendID
let status: SessionStatus // idle/running/awaitingApproval/awaitingInput/finished/error
let title: String
let branch: String
let lastSeq: UInt64
let diffStat: DiffStat? // +added / −removed / files
let updatedAt: Date
}
struct SessionSnapshot: Codable {
let summary: SessionSummary
let recentEvents: [AgentEvent] // tail window, oldest→newest
let pendingApprovals: [ApprovalRequest]
let cursor: UInt64 // client continues from here
}
5.3 Catch-up & history
- Cold subscribe (
sinceSeq == nil): host returns asnapshot— current status, pending approvals, and the last N events — not the whole transcript (which can be huge). - Full-transcript sync (
fetchTranscript(sessionID, afterSeq)→transcriptChunk* →transcriptFetchComplete|transcriptUnavailable): a device pulls a session's entire transcript after a seq cursor (afterSeq == 0= whole history, with the header), streamed as ordered chunks. Unlikesubscribe, any peer can serve it — from a live controller OR an on-disk transcript it holds (owned or mirrored) — so a Mac fetches a peer's full history on open (seeding from its local mirror + resumingsubscribeatsinceSeq = heldSeq, so the reply is just the gap), and a third Mac can re-serve it when the owner is offline. Gated onWireCapabilities.canSyncTranscripts; read-only (scope ≥ view). Macs proactively mirror all active, non-archived sessions over LAN/Tailnet; the relay stays on-demand. SeeTranscriptSyncMessages.swift,TranscriptMirrorStore,TranscriptSyncCoordinator. - Warm resubscribe (
sinceSeq = k): host replays events> kfrom the canonical transcript JSONL, then live-tails. Because the transcript is the source of truth (BACKEND_PROTOCOL §6), catch-up is just a file read from offset. - Client dedupes on
(sessionID, seq); replays are harmless — but only within a revert epoch (§1.4). A client that missed a revert (offline across it) detects the movedrevertEpochon the nextsessionList/sessionUpdatedand rebuilds: drop the held copy, refetch/resubscribe cold. Full-transcript fetches verify the holder's served epoch against the owner's advertised one before committing, so a stale third-party holder (or a fetch racing a fresh revert) is rejected rather than stitched in at reused seqs.
5.4 Approvals across multiple clients (first-responder-wins)
The host broadcasts approvalRequested to every subscriber of that session (and the Mac UI).
The first valid approvalRespond the host accepts wins; it forwards the Decision to the
backend (BACKEND_PROTOCOL §4) and broadcasts approvalResolved so other clients dismiss the
prompt. Late responses get error(.alreadyResolved). decidedBy on ApprovalResolved
records which device answered — a ResponderLabel ("<device name> · <fingerprint>") built
from the local Mac's identity, or, for a remote answer, the authenticated peer's name + the
short fingerprint of its key.
5.5 Throttling / verbosity (ties to BACKEND_PROTOCOL open-Q #4)
Verbosity lets a constrained client opt down:
.full— everyAgentEvent, including partialassistantText/toolCallInputDelta. Good on LAN..coalesced— host buffers partial deltas and flushes on block boundary or every ~150 ms; collapsestoolCallInputDeltainto the finaltoolCallCompleted. Default over relay/cell..statusOnly— onlysessionUpdated,approvalRequested,runFinished. For background / lock-screen monitoring.
The host coalesces before encryption, per subscriber, so each client gets the firehose it asked for without affecting others or the canonical transcript.
6. Push notifications (wake the phone)
When a session enters awaitingApproval or awaitingInput and the target device has no
foreground subscription, the host triggers an APNs push (directly if it has creds, else
via the relay/push service later). Payload is deliberately minimal — session title +
reason, no code or diff content — for privacy and because the push path isn't the E2EE
channel. Tapping the notification opens the app, which establishes the SecureChannel and
pulls the actual ApprovalRequest over the encrypted link.
Device push tokens are registered with the host at pairing/connect; the relay (later) stores
only deviceID → pushToken, never content.
7. Connection lifecycle
discover/relay ─▶ tcp/ws connect ─▶ Noise handshake (IK) ─▶ hello/welcome
▲ │
│ reconnect (backoff) ▼
└──────────────── drop ◀── live: subscribe → snapshot → events/approvals
- Drop (sleep, network change, transport switch): client reconnects with backoff,
re-handshakes (no re-pairing), re-subscribes each open session with its last
seq. Idempotent. - Host sleeps / quits: sessions are local processes that keep running (or are suspended by the OS); on host wake the canonical transcript still holds the truth and clients resync.
- Revocation: removing a device on the host drops its pinned key; its next handshake fails
with
error(.unauthorized).
8. Errors
struct WireError: Codable { let code: Code; let message: String; let sessionID: SessionID? }
enum Code: String, Codable {
case unauthorized // bad/revoked device or insufficient scope
case unknownSession
case alreadyResolved // lost the approval race
case unsupported // e.g. interrupt on a non-control scope
case backpressure // host overloaded; client should back off
case protocolVersion // version mismatch; client must upgrade
}
9. Versioning
hello/welcomeexchange a protocol version; mismatch →error(.protocolVersion).- Messages decode leniently; unknown enum cases/fields are ignored (forward-compatible), so a
newer host can add
HostMsgcases without breaking older clients.
10. Open questions
- Noise pattern choice — XX for pairing + IK for reconnect is the plan; confirm against a vetted Swift Noise implementation (or wrap libsodium / use CryptoKit primitives directly).
- APNs ownership — does the Mac host hold APNs credentials directly (needs an app server / provider token), or is push delegated entirely to the Cloudflare relay phase? Likely the latter, which means no remote push until M5 (LAN clients only get in-app/local alerts).
- Relay token model — issuance, rotation, and revocation of
relayToken; how the room is provisioned at pairing before the relay exists (forward-compat field in the QR). - History paging — finalize
loadBeforesemantics + how far back the phone can scroll vs. what stays Mac-only. - Control scope on iPhone — v1 is
approve-only; decide when/if to exposestartSession/merge/discardfrom the phone (scope=control), which expands the message set in §5.1.