# 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 / Covalence), 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 / Covalence — 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(_:)`. - **One project, one row** (whatever device it's on): every device in a mesh holds the same projects, so a peer's copy of a project we also hold is *never* a second section — its chats fold into ours and carry the origin themselves (`SessionSummary.foldedHostLabel` → a globe on the row). Devices register projects independently, so the same repo has a different `ProjectID` on each one; `AppStore.localProjectID(forPeerProject:on:named:)` maps a peer's id onto ours via `ProjectDescriptor.matches` (UUID → root-commit SHA → normalized remote) read from the peer's presence card, falling back to the project *name* for the window before any card is published (presence is cast-backed, so a LAN-only pair may have none). A runner's clone that matches on none of those is still folded by its `CovalenceSessionMirror` record. Only a project a peer holds and we genuinely don't gets a header of its own, so its chats aren't lost. Tests: `MeshUnifiedSidebarTests.peerCopyOfALocalProjectFoldsIntoOneSection`, `.peerProjectFoldsByRepoIdentityWhenNamesDiffer`, `.sameProjectOnTwoOriginsSharesOneSection`. - **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 (the wire verb `markSessionDone` exists — the phone's Sessions list and chat ⋯ menu use it, gated on `WireCapabilities.canMarkSessionDone` — but this sidebar doesn't track that capability per peer yet, so peer rows stay hidden), 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), `SessionCarbonCopy` (the Carbon Copy replica — 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 " 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 " (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//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`. **Transfer from either end — "bring it here" / brokered peer→peer (done + tested):** the menu used to appear only on a chat this Mac owns, because the wire had no way to ask another Mac to let go of one. `ClientMsg.requestSessionTransfer(SessionID, deviceID)` closes that: the requester only *names* the destination and the **owner runs its own `moveSession`**, so every transfer still originates at the machine holding the worktree — the 2PC contract above is untouched. Gated by `WireCapabilities.canTransferOnRequest` (field-additive; an older owner decodes `false` and the menu stays hidden) plus the same `.control`-scope + session-owning-peer check as the transfer verbs, and run off the message loop so packaging a session doesn't stall the connection. `AppStore.transferDestinations(for:)` answers for either origin: a local chat lists the connected session-owning peers as before; a peer's chat lists **this Mac** (always — it's connected to the owner by definition) plus the owner's own online session-owning peers, which is what makes a peer→peer move this Mac merely brokers possible. `AppStore.transferSession(_:to:)` routes accordingly. Tests: `WireMessageTests.transferClientMessagesRoundTrip` + `.transferOnRequestCapabilityToleratesOlderHosts`, `MeshUnifiedSidebarTests.remoteSessionOffersThisMacAndTheOwnersOtherPeers`. **Covalence fold on transfer (done + tested):** moving a chat *to a cloud runner* records the same `CovalenceSessionMirror` a runner **dispatch** does (`AppStore.noteTransferLanding`, on both commit paths and on tombstone recovery) — the backstop for a runner clone whose repo identity doesn't match ours, where the general one-project-one-row fold above can't tie the two together and the user would see the project twice, once local and once globe-badged. Test: `MeshUnifiedSidebarTests.transferToCloudRunnerFoldsUnderTheOriginProject`. **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`).