Files
nucleic/docs/MESH_TRANSFER.md
T

36 KiB
Raw Blame History

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), 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 (resolveEnabled/saveEnabled, key nucleic.sync.transports, env NUCLEIC_SYNC_TRANSPORTS), migrates the legacy single-choice value (tailnet{lan, tailnet}).
  • CompositeSyncListener (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 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: 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 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 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 (PoP proof matches the Worker byte-for-byte), 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.swiftRelayAPI (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 (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 (SyncListener demuxing the room socket into per-tag virtual FrameChannels 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: PeerKind, PeerCapabilities, WireClientCapabilities, PeerSummary.
  • Hello.deviceKind + clientCaps (optional); WireCapabilities.canListPeers; ClientMsg.listPeersHostMsg.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); 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): 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): 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): "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) 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) 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) 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): 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 + 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/): 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.Events 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, 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) 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) 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/<id>/staged-session.json (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 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. The container service no longer has a user-facing off switch — it is always on — so the test bundle instead pins nucleic.container.serviceDisabledForTesting=1 in each suite's isolated task-local defaults (.isolatedContainerSettings). If a run still hangs, pre-seed that key in the swiftpm-testing-helper defaults domain.