# Nucleic — Multi-Device Mesh + Session Transfer

> **Status (2026-07-04): in progress.** Phases 1, 2 & 4 done — the relay data path is live
> end-to-end against the deployed `nucleic-edge` Worker; **Phase 5 feature-complete** except the two-Mac memory-carry spike (engine +
> destination + source-driver/UI + moved-session visibility + relaunch recovery + bulk hand-off +
> arrived-from provenance + stranded-arrival "activate anyway"); **Phase 3 in progress** —
> iOS build unbroken (was failing since the P4/P5 merge) + `IdentityStore` multi-host registry +
> migration + host switcher + the *simultaneous* `[HostID: HostConnection]` multiplexer (compiles +
> demo-verified; live two-Mac test + host-qualified notifications remain). Test counts below are green as of this writing (Swift:
> 775 core + 107 protocol; edge: 50). The iOS app builds + runs in the simulator (`xcodebuild`). Design
> derived from a 6-lane code explore + 3 design passes + adversarial critique; full working notes in
> the session plan `lively-puzzling-cloud.md`.

## Goal

Any Nucleic device (Mac, iPhone, future cloud `nucleicd`) connects to any other over the
user-selected set of connection methods, picking the best available path automatically — the user
chooses methods via multi-select (Local network / Tailnet / Nucleic Private Relay), and the UI
strongly recommends at least Tailnet or Relay. Plus **session transfer between Macs** (take the
laptop to work → hand sessions to a Mac that stays home). A stepping stone toward Nucleic Cloud
([CLOUD_RUNTIME.md](CLOUD_RUNTIME.md)), not a fork.

## Locked invariants

1. **N hosts × M viewers over the one wire protocol.** No replication/consensus; each session has
   exactly one owning host (single-writer `SessionController`). A Mac is both a host (existing
   `SyncHost`) and a client (dials sibling Macs with the same `SyncClient` the iPhone uses).
2. **`SyncProtocol.version` stays 1.** Everything additive + capability-gated. `ClientMsg` decoding
   throws on unknown tags → a client must not send a new verb unless the host advertised the
   matching `WireCapabilities` flag. New `HostMsg` tags are safe (`.unknown` fallback). New struct
   fields use `decodeIfPresent` defaults (the `canFetchDiff` pattern).
3. **`HostID` = full SHA-256 hex of the host static key** (`DeviceIdentity.hostID`), never the
   display name. Multi-host clients key all state by `(hostID, sessionID)`; the wire stays
   unqualified (one connection = one host). The relay `roomId` is the same string.
4. **Transfer = 2-phase commit, tombstone-before-commit.** Git state moves as a `git bundle` over
   the E2EE channel (no shared-remote requirement). Tombstone = `moved_to_device_id` columns +
   `archived`, **not** a new `SessionStatus` case (that would silently break shipped iPhones' list
   decode). Agent-memory carry (native transcript + `backendSessionID`) is best-effort with a
   fresh-context fallback; credentials never transfer.
5. **Every difference is a declared capability, never an assumption.**

### Canonical names

`PeerKind` (raw-string: mac/iphone/cloud) · `PeerCapabilities {canHost, canRunAgents}` ·
`PeerAddresses {lanHint, tailnet, relayRoomID}` · `SyncTransportSet` (key `nucleic.sync.transports`)
· `Hello.deviceKind` + `Hello.clientCaps: WireClientCapabilities {mesh, canHost, canRunAgents}` ·
`WireCapabilities.canListPeers` / `.canReceiveSessionTransfer` · Mac dialer `PeerClient` · iOS store
key `nucleic.pairedHosts`.

---

## Phase status

### ✅ Phase 1 — Multi-select connection methods (Mac) — DONE, tested

- `SyncTransportSet` in [TailnetTransport.swift](../Sources/NucleicCore/Sync/TailnetTransport.swift)
  (`resolveEnabled`/`saveEnabled`, key `nucleic.sync.transports`, env `NUCLEIC_SYNC_TRANSPORTS`),
  migrates the legacy single-choice value (`tailnet` → `{lan, tailnet}`).
- `CompositeSyncListener` ([SyncTransport.swift](../Sources/NucleicCore/Sync/SyncTransport.swift))
  is now **partial-failure tolerant** with an `onChildStartFailure` callback; only a total failure
  throws. A pending Tailscale login no longer takes down LAN.
- [AppStore.swift](../Sources/NucleicCore/AppStore.swift) `startSyncServer` listens on every enabled
  method at once; per-method health in `syncActiveTransports` / `syncTransportHealth`; pairing QR
  carries all live hints; `HostInfo.hostID` fixed to `DeviceIdentity.hostID`.
- [RemoteAccessView.swift](../Sources/NucleicApp/RemoteAccessView.swift): three method toggles
  (Local network / Tailnet / Nucleic Private Relay — relay disabled until Phase 2), status dots, a
  non-removable last method, and a persistent **LAN-only recommendation banner**.
- Tests: `SyncTransportSettingTests`, `CompositeSyncListenerTests`.

### ✅ Phase 2 — Relay data path — DONE; live relay smoke-tested end-to-end

**Done + verified (security-critical):**
- Worker hardening in `cloud/nucleic-edge`:
  [relayEnroll.ts](../cloud/nucleic-edge/src/relayEnroll.ts) adds **X25519 proof-of-possession**
  enrollment — a host must prove it holds the static private key that derives its `roomId`, closing
  the hole where anyone holding the *public* host key could enroll and evict the real host.
  Server-side `roomId = sha256(staticKey)` derivation; token minting bound to the PoP-proven room.
  [room.ts](../cloud/nucleic-edge/src/room.ts) replaces broadcast-to-all with **role-routed per-peer
  forwarding** (a `[0x01][8-byte deviceId tag]` envelope, one-host-per-room eviction, 20 MiB frame
  cap). Tests: `test/relayEnroll.test.ts`, `test/room.test.ts` (edge suite 50 green, typecheck +
  wrangler dry-run clean).
- Cross-stack contracts pinned to test vectors so a deploy can't silently break:
  [RelayEnrollment.swift](../Sources/NucleicProtocol/Sync/RelayEnrollment.swift) (PoP proof matches
  the Worker byte-for-byte), [RelayEnvelope.swift](../Sources/NucleicProtocol/Sync/RelayEnvelope.swift)
  (routing tag + wrap/unwrap match `room.ts`), `PairingPayload` relay fields (roomID / token / URL,
  additive). Tests: `RelayEnrollmentTests`, `RelayEnvelopeTests`, `PairingPayloadTransportTests`.

**Formerly deploy-gated remainder — landed once `nucleic-edge` went live (relay.nucleic.blakeslee.xyz):**
- Shared client leg: [RelayTransport.swift](../Sources/NucleicProtocol/Sync/RelayTransport.swift) —
  `RelayAPI` (one base URL for REST + WS; `NUCLEIC_RELAY_URL` dev override), `RelayWebSocket`
  (ordered sends, ping keepalive, ping-confirmed connect), `RelayFrameChannel` (membership →
  connection token trade, `WireFraming` prefix inside WS binary messages, fail-fast when presence
  says the room has no host), `RelayPresence`.
- Host side: [RelayAccess.swift](../Sources/NucleicCore/Sync/RelayAccess.swift) (PoP enrollment via
  `RelayEnrollment`, room credential in the login Keychain — separate from the push credential —
  membership minting with re-enroll-on-401) and
  [RelayListener.swift](../Sources/NucleicCore/Sync/RelayListener.swift) (`SyncListener` demuxing
  the room socket into per-tag virtual `FrameChannel`s via `RelayEnvelope`, presence-driven channel
  reaping, capped-backoff redial). Wired into `AppStore.startSyncServer` behind the `.relay`
  transport checkbox (now enabled in `RemoteAccessView`); `currentLocalAddresses` advertises
  `relayRoomID`.
- Credential distribution, both routes: the pairing QR carries a bootstrap membership
  (`beginPairing` mints for a fresh `pair-…` id, so first contact can ride the relay), and a new
  additive `HostMsg.relayMembership` (`WireRelayMembership`) is pushed after every hello
  (`SyncHost.register` → mint bound to the device's real id) — which is how devices paired
  *before* the relay adopt it and how the ~90-day token refreshes. Old phones ignore the unknown
  tag (`.unknown` fallback).
- iOS: relay is a real dial candidate (always last — direct paths win) in `HostConnection`
  pair + reconnect; `PairedHost` persists `relayRoomID`/`relayMembershipToken`/`relayURL`
  (decode-defaulted); the `relayMembership` push updates the registry in place (no reordering);
  10 s handshake watchdog on the relay leg mirrors the LAN one.
- Verified: `RelayTransportTests` (URL building, presence, membership codec),
  `RelayListenerTests` (demux/reap/redial over a fake room socket), and a **live smoke test**
  against the deployed Worker — PoP enroll (server room derivation matches), host+client token
  mints/trades, and a two-socket frame round-trip through the Room DO with correct envelope
  tagging both directions.

**Known limits:** host-side revoke-on-unpair isn't wired (`/v1/relay/revoke` is admin-only today;
membership tokens age out in ≤90 days and Noise still gates content). A device that reconnects
mid-session keeps its tag, so the host converges through the fresh handshake failing the stale
handler (one retry) rather than instantly. Mac↔Mac (`PeerClient`) doesn't dial the relay yet —
`PeerAddresses.relayRoomID` is advertised, but sibling Macs hold no membership tokens.

### ✅ Phase 4 — Peer model, Mac↔Mac pairing, presence — DONE, tested

**Foundation (P4a):**
- [PeerTypes.swift](../Sources/NucleicProtocol/Sync/PeerTypes.swift): `PeerKind`,
  `PeerCapabilities`, `WireClientCapabilities`, `PeerSummary`.
- `Hello.deviceKind` + `clientCaps` (optional); `WireCapabilities.canListPeers`;
  `ClientMsg.listPeers` → `HostMsg.peerList([PeerSummary])`; `PairedDevice.kind` + `capabilities`
  (decode-defaulted custom `init(from:)`, so pre-mesh JSON stores load unchanged).
- `ConnectionHandler` records `deviceKind` at pairing; `SyncHost.connectedDeviceIDs()`;
  `AppStore.peerSummaries()` advertising `canListPeers`; `SyncClient` threads
  `deviceKind`/`clientCaps` + surfaces a `.peerList` event; iOS `RemoteStore.meshPeers`.
- Tests: `WireMessageTests` (codec + backcompat), `SyncHostTests` (loopback `listPeers` + device-kind
  at pairing), `FilePairedDeviceStoreTests` (pre-mesh decode defaults).

**Remainder (P4b) — done + verified:**
- **Addresses on the wire:** `PeerAddresses {lanHint, tailnet, relayRoomID, updatedAt}`
  ([PeerTypes.swift](../Sources/NucleicProtocol/Sync/PeerTypes.swift)); optional
  `Hello.addresses` / `Welcome.addresses`; `ClientMsg.addressUpdate(PeerAddresses)` gated on new
  `WireCapabilities.canUpdateAddresses` (the sender refreshes its *own* store row);
  `PairedDevice.addresses` (decode-defaulted). The relay hint is populated whenever the
  relay method is up (P2).
- **`PeerClient`** ([PeerClient.swift](../Sources/NucleicCore/Sync/PeerClient.swift)): the Mac
  dials paired sibling Macs with the same `SyncClient` the iPhone uses — `.control` claim,
  `deviceKind: mac`, `clientCaps {mesh: 1, canHost, canRunAgents}` — serial LAN→tailnet dial
  with per-candidate timeout, capped backoff, live presence stream, `listPeers` only when
  advertised. Dial seam ([PeerDialer.swift](../Sources/NucleicCore/Sync/PeerDialer.swift)):
  `MacPeerDialer` = new outbound `LANDialChannel` (dial-side `LANChannel` with a TCP-ready
  timeout) + `TailnetNode.shared.dial` off the node the listener already runs.
- **Symmetric pairing, one store.** `pair(with:)` (pasted `nucleic://pair` link) pins the
  remote Mac into the *same* `PairedDeviceStore` the host pins inbound devices into — and the
  accepting Mac's ordinary hello path pins the joiner — so one paste makes **both directions**
  dialable (what P5's "source dials destination" needs regardless of who pasted). Records with
  `kind.canOwnSessions && capabilities.canHost` are the dial targets; revoke = one store row.
- **Accepting-Mac confirm (locked decision #4):** mac-kind first pairings consult
  `SyncHostBridge.approveMacPairing` (defaulted true for tests/minimal hosts); `AppStore`
  publishes `pendingMacPairRequest` → confirm dialog on the QR sheet, 120 s fail-closed
  timeout, denied on sheet close/server stop. Phones keep pure TOFU.
- **`SyncHost` per-deviceID dedup:** keep-newest with a configurable grace
  (default 2 s) — the old handler is closed after the grace; registry keyed post-hello only.
- **UI** ([RemoteAccessView.swift](../Sources/NucleicApp/RemoteAccessView.swift)): "Paired
  Macs" section (presence dot, transport, revoke, per-peer error), "Pair another Mac…"
  paste-link sheet, QR sheet doubles as copy-link ("Add device…"), confirm dialog.
  `AppStore.meshPeers` / `inboundConnectedDeviceIDs` publish live presence
  (`SyncHostBridge.meshPeersChanged` fires on pair/connect/disconnect/addressUpdate, which
  also fixed the stale `pairedDevices` list).
- Tests: `WireMessageTests` (PeerAddresses codec, legacy tolerance, addressUpdate, capability
  flag), `SyncHostTests` (dedup keep-newest + distinct-device coexistence, hello/welcome
  address exchange, addressUpdate → store, mac-confirm decline/phone-skip),
  `PeerClientTests` (pair-via-link pins both sides, IK reconnect after drop, `listPeers`
  gating vs a non-advertising host, unpair, self-pair/garbage-code rejects, endpoint
  derivation incl. bracketed IPv6), `FilePairedDeviceStoreTests` (foundation-era JSON without
  addresses still loads).

**Hardening (adversarial review pass):** a 4-dimension × 2-refuter review surfaced and fixed —
the mac-pairing confirm is now enforced on the *reconnect* promotion path too (a device can't
pair as a phone then reconnect claiming `deviceKind: mac` to skip the confirm), and the
existing-device hello branch requires the just-authenticated static key to match the pin (a
pairing party can't claim another device's `deviceID` to hijack its record/registry slot); the
`startPeerClient` post-commit awaits are guarded against a racing `stopSyncServer` (host-token
check); the mac-pairing confirm timer is stored and cancelled on resolve (no stale timer denying
a later attempt); `pair()` treats a pre-welcome `wireError` as terminal (no re-prompting the
accepting user per hinted endpoint) and classifies decline vs unreachable; `consume()`/`pair()`
have a handshake deadline (a silent endpoint no longer wedges the manage loop); `setPresence`
won't resurrect an unpaired peer from an in-flight event; `meshPeersChanged` no-ops once the
server is down (a closing handler no longer wipes `pairedDevices`); the pairing UI cancels an
in-flight `pair()` on dismiss; `LANDialChannel` cancels on `.failed` and enables TCP keepalive.

**Known limits:** `addressUpdate` fires only when this Mac's own addresses change (the pre-pair
refresh + `setLocalAddresses`); it's not yet wired to a live listener-port watcher. Mac↔Mac
revoke is one-sided — the revoked Mac keeps dialing and reads as offline (it can't distinguish
revocation from the host being down). Half-open detection is TCP-keepalive on the LAN path only;
a tailnet session's liveness is the tsnet layer's concern.

### ◐ Phase 3 — iOS multi-host — FOUNDATION STARTED

**iOS build unbroken first (prereq):** the P4/P5 mesh work added `ClientMsg` /
`SyncClient.Event` cases that were never handled in `RemoteStore`'s two exhaustive switches (the
demo-simulator `ClientMsg` handler + the event handler), so `NucleicRemote` didn't compile —
`swift build` never builds the iOS target, so it landed unnoticed. Fixed (the new cases are inert on
a phone). The iOS app now builds for the simulator (`xcodebuild … BUILD SUCCEEDED`), so Phase 3 work
is compile-verifiable here.

**Paired-host registry (done + iOS-build-verified):** `IdentityStore`
([IdentityStore.swift](../ios/NucleicRemote/NucleicRemote/Models/IdentityStore.swift)) single
`nucleic.pairedHost` slot → a `nucleic.pairedHosts` registry (ordered `[PairedHost]`, keyed by
`fingerprint` = hostID) with a one-time migration of the legacy value. New API
(`pairedHosts`/`pairedHost(id:)`/`upsertPairedHost`/`removePairedHost`) for the coming multiplexer; a
single-host bridge keeps every caller + behavior unchanged (`loadPairedHost` = active/most-recent,
`savePairedHost` = upsert+activate, `clearPairedHost` = remove active). `SettingsView` gains a
"Paired Macs" list (each host + fingerprint + "Active" + per-host remove) — the visible artifact +
the removal path now that a new pairing keeps (not overwrites) the prior Mac.

**Host switcher + 2-host demo (done + simulator-verified):** the safe, verifiable slice — switch
*which* paired Mac the phone views, reusing the existing (proven) single-connection reconnect rather
than rewriting the connection state machine. `RemoteStore`
([RemoteStore.swift](../ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift)) gains
`activeHostID` (the Mac the flat `sessions`/`hostName`/`dashboard` projection reflects), `hostChoices`
(paired registry live / mock hosts in demo), `switchHost(to:)` (live re-points via `reconnect()`;
demo swaps the mock host, preserving in-demo edits); `reconnect()`/`pair()` set the active host and
`unpair()` forgets the *active* Mac and falls back to a remaining one. Demo seeds two mock Macs; a
toolbar host-switcher menu in `SessionsView` shows when >1 Mac. Verified in the iOS Simulator (demo):
the menu lists both Macs and switching swaps the whole session projection + the tab badge. Single-host
behavior is unchanged (one Mac ⇒ switcher hidden ⇒ flat state exactly as before).

**Multiplexer (done, live path pending a two-Mac test):** `HostConnection`
([HostConnection.swift](../ios/NucleicRemote/NucleicRemote/Models/HostConnection.swift)) is the
per-host connection engine lifted out of `RemoteStore` — one instance owns a single Mac's
`SyncClient` + LAN→tailnet chain + reconnect + tailnet-node lifecycle + event stream, and keeps that
Mac's projection, talking back through a `Callbacks` struct. `RemoteStore` now owns
`connections: [HostID: HostConnection]` and **connects every paired Mac at once**: `reconnect()`
dials all (dropping since-unpaired ones), `pair()` adds a connection + makes it active while others
run, `unpair()` drops the active and switches to a remaining one. The flat `@Published` state mirrors
the *active* connection via callbacks, so **`switchHost()` is instant** (every Mac is already
connected — no reconnect). Aggregate concerns merge across hosts: the app badge counts needs-you
across **all** Macs, Live Activity summarizes all live sessions, and a notification-answered approval
broadcasts to every connection (no `hostID` in the notification yet). ~450 lines of connection
machine moved out of `RemoteStore`. Compiles + the demo host switcher is simulator-verified;
**the live multi-connection path (simultaneous dials, reconnect/teardown, the shared embedded tailnet
node) is compile-verified only** — needs two real Macs.

**Remaining:** the prerequisite spike (~1 h — confirm one `TailnetNode.shared` dials two Macs on the
same tailnet) and a live two-Mac test of the multiplexer; then host-qualified notifications / Live
Activity (`userInfo` carries `hostID` so `respondFromNotification` targets the owning host instead of
broadcasting). Optional next: an *aggregate* session list (all hosts' sessions grouped by Mac) rather
than the switch-active model.

### ✓ Mac sidebar — unified local/remote representation (mesh session sync)

The **Mac app** now represents peer Macs' projects and sessions *identically* to local ones,
through one abstraction layer rather than a separate mesh section:

- **Model**: `ProjectSummary` (`Project.swift`) is the project analogue of `SessionSummary` — one
  compact shape both origins map into, with `hostID`/`hostLabel` as the origin marker. The local
  `SessionSummary` gained `hostID` + `init(wire:hostID:)`, so one row model (and one `SessionRow`)
  serves both. Row identity is origin-qualified (`sidebarRowID`) because a moved session's local
  tombstone and its live twin mirrored back from the new owner share a raw `SessionID`.
- **Lists**: `AppStore.sidebarProjects` interleaves every connected peer's projects with local ones
  (name-sorted, like the local list); `summaries(for:)`/`archivedSummaries(for:)` serve remote
  projects from the peer's mirrored session list through the same comparators; Recents and the
  recent-count union the mesh. `projectSummary(_:)` is the origin-agnostic `project(_:)`.
- **Selection**: `openSessionID.didSet` routes remote sessions itself (via
  `remoteSessionLocation`, which prefers local records so tombstones aren't hijacked) — every
  writer of `openSessionID` opens local and remote identically. `openSession(_ summary:)` is the
  row-level opener that honors the row's own origin.
- **Verbs**: mesh Macs grant each other `.control`, so the sidebar's context menu / swipes and the
  chat header dispatch the same wire verbs a phone uses (`PeerClient.sendCommand`): favorite,
  archive, delete, interrupt, rename, model/effort/auto/ship, and `startChat` into a remote project
  (new-chat composer picks across the mesh; `RemoteProjectView` is the overview page). Owner-side
  fixes make mirrors converge: `mutateSession`(+`ForRemote`) broadcast `sessionUpdated`, and
  `deleteSession` pushes a fresh `sessionList` (deletions had no broadcast at all).
- **Still local-only** (hidden, not inert, for remote): Mark Done (no wire verb), Move to Mac (no
  pull-transfer), project-level rename/archive/delete/convert, branch/worktree/attachments/ship in
  the composer, and the worktree-rooted chrome (panels, Build/Run, git ops).

### ◐ Phase 5 — Session transfer Mac→Mac — ENGINE + DESTINATION DONE; source-driver + UI pending

**Engine (done + tested):** the full 2-phase-commit transfer engine, proven end-to-end by a
loopback test (real temp git repos + real GRDB + in-memory channel):
- **Wire** ([TransferMessages.swift](../Sources/NucleicProtocol/Sync/TransferMessages.swift)):
  `TransferOffer`/`Accept`/`Reject`/`Chunk`/`ChunkAck` payloads, `SessionTransferRecord`,
  `ProjectDescriptor` (matches by UUID / root commit / normalized remote), `TransferRejectReason`
  (raw-string, forward-compatible). `ClientMsg` source→dest verbs + `HostMsg` dest→source replies;
  the replies **also** map to `SyncClient.Event` + `messageLoop` (the source receives them as a
  client). `WireCapabilities.canReceiveSessionTransfer` gates every source send.
- **Persistence (v23):** `moved_to_device_id`/`moved_at` tombstone columns (rides the existing
  `archived` read/sweep path — **not** a new `SessionStatus` case) + a `session_transfer` table with
  a UNIQUE-while-active index; store methods `beginTransfer`/`updateTransferState`/`activeTransfer`/
  `allActiveTransfers`/`tombstoneSession`/`activateTransferredSession` (atomic + idempotent).
- **Git** ([TransferGit.swift](../Sources/NucleicCore/Transfer/TransferGit.swift) +
  [WorktreeManager.swift](../Sources/NucleicCore/Git/WorktreeManager.swift)): `createTransferBundle`
  (prereq-based, prereq-free fallback), `createFromTransfer` (verify + fetch + worktree add),
  `discardAfterTransfer`, `presentSHAs`/`branchExists`; `TransferGitProbe` for repo shape
  (shallow/partial/LFS/submodule) + identity. git-bundle round-trip verified against real repos.
- **Transcript:** `SessionHeader.maxSupported` + `TranscriptHeaderRewriter` (repoint paths / drop
  `backendSessionID` for the fresh-context fallback) — the first consumer of transcript versioning.
- **Coordinator/Importer/Package** ([Transfer/](../Sources/NucleicCore/Transfer/)):
  `SessionTransferCoordinator` (source, tombstone-before-commit, windowed chunk acks, typed
  errors), `SessionTransferImporter` (dest, stage-non-runnable → activate-on-commit, best-effort
  native memory carry with fresh-context fallback), `SessionTransferPackage` (items + per-item
  sha256, bundle built post-accept vs `haveSHAs`).

**Destination integration (done + tested):** `ConnectionHandler` dispatches the transfer verbs
gated on `.control` + mac peer (`peerKind` now persisted); `AppStore` owns the importer via a
`StandardTransferImportEnvironment` (project resolution by UUID/identity, native-path computation,
`activateTransferredSession` → reconstruct controller + broadcast), advertises the capability, and
teaches the archived-worktree sweep to skip a mid-transfer session. Tested through the real
`AppStore` bridge (capability advertised, unknown-project reject, known-project accept w/ haveSHAs).

**Hardening (adversarial review, 4 dims × 2 refuters):** fixed the critical
"never-runs-in-two-places" gap — a DB tombstone alone didn't make the source session inert, so
during the post-tombstone commit round-trip a connected peer could still `sendInput`/list/snapshot
it. Now a moving session is held in `transferringSessions` (hidden from `sessionSummaries`/
`snapshot`, refuses `sendInput`) for the whole transfer, `loadSessions` never rebuilds a controller
for a `movedToDeviceID != nil` session (relaunch safety), and any post-tombstone commit failure
surfaces as `committedButUnconfirmed` — which tears the source down (inert) and keeps the lock at
`.tombstoned` (worktree preserved) for recovery, never reviving or double-running it. Also fixed:
path-traversal in the importer (validate `transferID`/`sessionID`/branch as safe components before
building any filesystem path), the source now checks `canReceiveSessionTransfer` before sending,
`tearDownMovedSession` fully shuts the controller + reaps its container (no leak), and
`resolveTransferProject`'s UUID fast-path no longer matches an archived project.

**Source driver + UI (done + tested):** `PeerClient.openTransferChannel(to:)` binds a
`PeerClientTransferChannel` to the destination's live `SyncClient`, routing the transfer-reply
`SyncClient.Event`s through per-peer sinks (and `.disconnected` on a mid-transfer drop);
`PeerPresence.canReceiveSessionTransfer` is learned from the welcome. `AppStore.moveSession(_:to:)`
classifies transferability (`TransferClassifier` for status / nvrsion / nested-or-parent /
no-worktree + `TransferGitProbe` repo shape), quiesces via `finalize(.auto("nucleic: transfer
snapshot"))`, packages the branch + transcript (+ best-effort native memory) and the
`ProjectDescriptor`, then runs the coordinator over the peer channel; on success the source is
tombstoned + torn down. A "Move to Mac" submenu on the sidebar row lists connected peer Macs that
advertise the capability. Tested end-to-end over a **real** `PeerClient` channel + `SyncHost`
(`PeerTransferChannelTests`), not just the in-memory loopback.

**Moved-session visibility (done + tested):** a moved session no longer silently vanishes.
`SessionSummary.movedTo: MovedDestination? {deviceID, deviceName}`
([WireMessages.swift](../Sources/NucleicProtocol/Sync/WireMessages.swift), additive /
decode-defaulted, trailing init param) carries where it went. The source keeps the tombstone as a
read-only row: `AppStore.movedDestination(for:)` resolves the destination's name from the
paired-device store; `wireSummary` stamps it; `finalizeMovedTeardown` re-upserts an archived+moved
Core summary; `loadSessions` re-surfaces moved tombstones on relaunch **without** rebuilding a
runnable controller; and `sessionSummaries()` includes them (from the DB) so a (re)subscribing
phone sees them. The Mac sidebar renders "Moved to <Mac>" under Archived (name resolved live from
`pairedDevices`, so it upgrades past the persisted fallback once the sync server loads), suppresses
the mutating row actions, and the iOS row renders the host-baked name. Tests:
`WireMessageTests.sessionSummaryCarriesMovedTo` (+ legacy tolerance),
`AppStoreTests.movedSessionSurfacesAsTombstoneAfterRelaunch` (relaunch surfacing, no revive,
wire marker). iOS view edits not compiled here (separate Xcode target).

**Relaunch recovery (done + tested):** `AppStore.recoverInterruptedTransfers()` reconciles any
transfer a crash/quit left mid-flight — driven from launch (after `loadSessions` in
[NucleicApp.swift](../Sources/NucleicApp/NucleicApp.swift)) and again on every peer (re)connect (a
fire-and-forget, single-flight call in `meshPeersChanged`). Abandoned pre-tombstone outbound locks
(`.offering`/`.streaming`/`.awaitingReady`) are cleared (the source session lives here again, so the
UNIQUE-while-active guard frees for a re-move); orphaned inbound `.staging` is dropped
(`discardStaleInboundTransfers`); and a durably-`tombstoned` outbound source finishes its 2-phase
commit via `SessionTransferCoordinator.recoverTombstoned` — a bounded, idempotent re-send of
`transferCommit` to the destination peer that, on the confirm, discards the stale source worktree
and clears the lock (a destination that also relaunched and lost its staging just leaves the lock at
`.tombstoned`, never reviving the source). Tests: `SessionTransferTests.recoverTombstoned*` (both the
reconnect-completes and destination-lost-staging paths, over the real loopback + GRDB) +
`AppStoreSyncBridgeTests.recoverInterruptedTransfersCleansUpAbandonedLocks`.

**Bulk hand-off (done + tested):** the "take the laptop home, hand its sessions to the Mac that
stays" flow. `AppStore.transferableSessions()` lists the eligible live chats (session-level
`TransferClassifier`, excluding archived / mid-transfer / mid-turn / nested / no-worktree);
`moveSessionsToPeer(_:to:label:)` runs the selected chats through the same single-session path in
sequence (one per-peer channel at a time), folding per-chat failures into one `lastError` rollup and
counting a `committedButUnconfirmed` as moved. UI: a "Hand off…" button on each connected,
transfer-capable Paired-Mac row ([RemoteAccessView.swift](../Sources/NucleicApp/RemoteAccessView.swift))
opens a `HandoffSheet` — a select-all-by-default checklist of eligible chats with a "Move N chats"
action. Test: `AppStoreSyncBridgeTests.bulkHandoffListsEligibleAndRollsUpFailures` (eligibility,
archived exclusion, unreachable-peer rollup + restore).

**Arrived-from provenance (done + tested):** the mirror of moved-to, on the destination. GRDB **v24**
adds `arrived_from_device_id` / `arrived_at` to `session`; the importer stamps them at staging (source
= the inbound offer's device id); `Session` carries the fields. Wire: additive
`SessionSummary.arrivedFrom: ArrivedFrom? {deviceID, deviceName}` (decode-defaulted). The Core summary
carries the *raw* source id (it rides every live-session summary, so no call site threads it) and the
Mac sidebar resolves the name live from `pairedDevices`, showing a subtle inbound chevron with the
source Mac in its tooltip; the wire summary bakes the resolved name (`AppStore.arrivedSource(for:)`)
so a phone renders "Arrived from <Mac>" (iOS folds it into the row subtext). Tests:
`WireMessageTests.sessionSummaryCarriesArrivedFrom`, `SessionTransferTests.happyPath…`
(real GRDB v24 round-trip of the provenance), `AppStoreTests.arrivedSessionSurfacesProvenanceInSummaries`.

**Stranded-arrival recovery — "Activate anyway" (done + tested):** the last 2PC gap — a destination
that relaunches after staging (`.ready`) but before commit. At `.ready` the importer now writes the
staged `Session` to `…/transfers/<id>/staged-session.json`
([SessionTransferImporter.swift](../Sources/NucleicCore/Transfer/SessionTransferImporter.swift)),
so a relaunch (which loses the in-memory offer/staged session) can rebuild it:
`recoverableInboundTransfers()` scans `.ready` inbound locks + reads each manifest (dropping any whose
manifest is gone as unrecoverable); `activateRecoveredTransfer(_:)` inserts the session + flips the
lock to `.activated` (reusing the commit path) + notifies the env to build the controller;
`clearInboundStaging(_:)` drops the staging for a discard. `AppStore.recoverInterruptedTransfers`
surfaces these as `pendingArrivedTransfers`; `activateArrivedSession`/`discardArrivedSession` drive
the choice (discard also tears down the imported worktree/branch + placed transcript). UI: an
"Interrupted arrivals" section in [RemoteAccessView.swift](../Sources/NucleicApp/RemoteAccessView.swift)
with per-chat Activate / Discard. Tests: `SessionTransferTests.recoverAndActivateStrandedInboundAfterDestRelaunch`
+ `.discardStrandedInboundClearsStagingAndLock` (real fresh-importer "relaunch" over the same
store/stagingRoot), `AppStoreSyncBridgeTests.recoverInterruptedTransfersSurfacesStrandedArrival`.

**Remaining:** the prerequisite spike (hand-copy a native transcript between two Macs, try
`claude --resume`) still gates whether memory carry stays on by default or the importer forces the
fresh-context fallback — the only Phase 5 item left, and it needs two physical Macs. (Nice-to-have
follow-up: clear the arrived-from marker once the user sends their first message in an adopted session.)

---

## Compatibility & migrations

| Store | Old → New | Phase |
|---|---|---|
| Mac transport | `nucleic.sync.transport` single → `nucleic.sync.transports` set | 1 ✅ |
| PairedDevice JSON | + `kind` / `capabilities`, decode-defaulted | 4 ✅ |
| PairedDevice JSON | + `addresses` (`PeerAddresses`), decode-defaulted | 4 ✅ |
| PairingPayload | + optional relay `roomID`/`token`/`URL` CBOR keys | 2 ✅ |
| iOS hosts | `nucleic.pairedHost` single → `nucleic.pairedHosts` registry | 3 ☐ |
| GRDB | + `moved_to_device_id`/`moved_at`; + `session_transfer` table (v23) | 5 ✅ |
| GRDB | + `arrived_from_device_id`/`arrived_at` (v24, transfer provenance) | 5 ✅ |

## Known test-isolation note

Headless `swift test` could deadlock: `AppStore` first-launch set the global
`nucleic.container.serviceEnabled`, flipping parallel `SessionController` tests into the sandboxed
path → a real Keychain read → an invisible ACL prompt → hang holding `ClaudeLoginKeychain.lock`. A
proper fix (task-local container settings + Keychain guard) has since landed on `dev`. If a run still
hangs, pre-seed the `swiftpm-testing-helper` defaults domain (`serviceEnabled=0`,
`firstLaunchCompleted=1`).
