Files
nucleic/docs/MESH_TRANSFER.md
T

468 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Nucleic — 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 <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`.
**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`).