# 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.
