Files
nucleic/docs/SYNC_PROTOCOL.md
T
abkslmandClaude Opus 4.8 0e47298720 Add Nucleic design plan: Swift/SwiftUI Claude Code/Codex wrapper
Comprehensive design for a Conductor-style macOS app (host) + iPhone
(thin remote client) that runs parallel Claude Code / Codex sessions in
isolated git worktrees, with interactive per-session approvals.

PLAN.md is the hub; docs/ over-specifies each layer:
- BACKEND_PROTOCOL: normalized AgentEvent model, capabilities, approvals
- ADAPTERS: Claude MCP approval server + Codex app-server JSON-RPC,
  with wire contracts confirmed from primary sources
- SYNC_PROTOCOL: LAN/relay E2EE sync, seq-cursor catch-up
- WORKTREE_MANAGER: worktree lifecycle, diff, integrate, reconcile
- RUNTIME_ARCHITECTURE: single-writer pipeline, GRDB schema, concurrency
- UX_MACOS / UX_IOS: information architecture and approval flows
- OBSERVABILITY_AND_TESTING: redaction-aware observability + fixture harness

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-11 23:49:19 -07:00

280 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `AgentEvent`s
and the approval round-trip from [BACKEND_PROTOCOL.md](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
1. **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.
2. **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.
3. **One message set, two transports.** LAN and relay differ only in how bytes move; the
encrypted `Frame`s and the application messages inside them are identical.
4. **Resumable & idempotent.** Every subscription is driven by the monotonic `seq` from the
backend protocol. Reconnect → re-handshake → re-subscribe `sinceSeq` → catch up. No
message is required exactly once; consumers dedupe on `(sessionID, seq)`.
5. **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._tcp` via Bonjour (`NWListener` + `NWTxtRecord` carrying host
identity-key fingerprint + protocol version).
- Client browses, resolves, opens an `NWConnection` (TCP). Then the `SecureChannel` handshake
(§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 opaque `Frame`s 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: client tries LAN first (Bonjour hit) and falls back to relay; host registers
with the relay whenever reachable. A session can migrate transports mid-stream because
`SecureChannel` + `seq` cursors make reconnection seamless.
---
## 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)
1. Mac shows a **QR** containing: host identity public key, a one-time pairing secret, LAN
connection hint, and (later) a relay `roomID` + bootstrap token.
2. iPhone scans → has the host's public key over a trusted OOB channel.
3. 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.
4. Host records the device in its **paired-devices** table with a `scope` (default `approve`)
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 `Frame`s 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
```swift
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 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
```swift
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 error(WireError)
case pong
}
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 a `snapshot` — current status, pending
approvals, and the last *N* events — not the whole transcript (which can be huge). The
client may page older history on demand (a future `loadBefore(sessionID, beforeSeq, limit)`;
out of v1 scope but the cursor model already supports it).
- **Warm resubscribe** (`sinceSeq = k`): host replays events `> k` from 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.
### 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.
### 5.5 Throttling / verbosity (ties to BACKEND_PROTOCOL open-Q #4)
`Verbosity` lets a constrained client opt down:
- `.full` — every `AgentEvent`, including partial `assistantText`/`toolCallInputDelta`. Good
on LAN.
- `.coalesced` — host buffers partial deltas and flushes on block boundary or every ~150 ms;
collapses `toolCallInputDelta` into the final `toolCallCompleted`. Default over relay/cell.
- `.statusOnly` — only `sessionUpdated`, `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
```swift
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`/`welcome` exchange a protocol version; mismatch → `error(.protocolVersion)`.
- Messages decode leniently; unknown enum cases/fields are ignored (forward-compatible), so a
newer host can add `HostMsg` cases without breaking older clients.
## 10. Open questions
1. **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).
2. **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).
3. **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).
4. **History paging** — finalize `loadBefore` semantics + how far back the phone can scroll vs.
what stays Mac-only.
5. **Control scope on iPhone** — v1 is `approve`-only; decide when/if to expose
`startSession`/`merge`/`discard` from the phone (`scope=control`), which expands the
message set in §5.1.