ArchitectureDraft
ADR 0042: The move engine
Status: draft, 28 Sep 2026 (revised after the reviewer's first pass) · Workstream: federation · Carries out ADR 0039 section 4 (docs/design/anywhere.md, not yet merged here) and the move contract already sent to launch and tailnet (team/archive/work-journals/federation.md, "Move engine: contract only")
Context
"Move to a server" (launch's Settings panel, already built against this contract) needs an engine behind it: copy a person's four pieces - projects, memory, vault, sessions - from one machine to another, without the source ever going dark mid-move, without a vault item ever sitting on disk outside a seal, and without deleting anything until the person says so twice (once to confirm the new home works, once to actually free the old one's space).
The team-lead's five constraints for this pass:
- The source stays live during the whole copy - never taken offline, never read-only, nothing about it changes until the person confirms.
- Vault items are re-encrypted end to end; never in plaintext on disk, on the source or the destination, at any point.
- The plan carries real counts and sizes per piece before anything moves.
- There is undo, and a confirm step that is separate from the copy finishing.
- Freeing space on the old machine is the person's own, separately previewed action - never a side effect of confirming the move.
Plus a transport decision: the relay introduces, Tailscale carries. Two machines that have never spoken need a way to find and trust each other first; once they have, the bytes go over the tailnet directly, not through the relay's own websocket bridge (ADR 0026), which is a control-plane path (pairing, small requests, presence), not built or sized for a whole vault and session history.
The reviewer's first pass on this note found three HIGHs the constraints above did not cover: who the destination really is (not merely "a reachable tailnet node"), that the destination must consent to receive, and that a vault carries secrets, never trust. This revision folds all three in before any code is written.
Decision
1. Four pieces, copied independently, confirmed together
Unchanged from the contract already sent out: projects, memory, vault, sessions. Each
copies and verifies on its own; nothing here waits on another piece to start, and a piece that
fails is retried on its own, not the whole move. The final flip (which piece "wins" as the
person's live machine) is a single gate all four must clear together - stage: "ready" is never
partial.
2. Who the destination is (reviewer's HIGH 1)
A move ships every secret the person has. "A reachable tailnet node" is not who: under the Wink
model a desktop is tagged, so tailscale whois gives back a tag, not the owner - a shared-tailnet
peer, a guest device, or someone else's box could all resolve to a live, reachable node without
being this person's own.
move.plan { destination } refuses anything the destination cannot be proven to be, checked
against a pairing record of this owner, not a whois lookup: the destination's pinned stable id
and its vyred's own static key, recorded when it was paired (tailnet's onboard.join/relay.join
flow, ADR 0026 - the move engine trusts that record, never re-derives identity from the network
itself). Needs from tailnet: which existing pairing table is the authoritative "this node is
one of the owner's own" record (relay_devices, link_peers, or a new one) - the move engine
reads it, never writes it, and never invents a second identity path the way federation's own
lib/caller.js note warns against elsewhere in this codebase.
Both sides authenticate each other before a byte moves: the destination checks the source's pinned identity the same way, from its own copy of the same pairing record. A move is refused, not merely declined, if either side cannot confirm the other from a pinned record rather than the network's say-so.
3. The destination must consent to receive (reviewer's HIGH 2, team-lead's headless-server note)
As drafted before this revision, anything that could reach the destination's move endpoint could push data into it - planting memory, secrets or sessions with nothing on the receiving end ever having agreed to accept them. The first fix (a token minted on the destination) assumed a person could walk up to it and mint one; the destination is very often a headless server with no screen anyone is standing in front of. Fixed the way ADR 0021 already answers this exact shape of problem for a Mac's own asks (a paired device answers something the machine that needs an answer cannot ask for itself): the destination raises the question, and any of the owner's already- paired devices answers it, with presence, from wherever they are.
move.start { planId }(run on the source, HUMAN_ONLY): once the destination's mutual identity check (section 2) passes, the destination raisesmove.receive.ask { moveId, source: { name, fingerprint } }instead of accepting anything yet.move.startitself answers{ stage: "awaiting_receive" }- no piece begins copying.- The ask reaches the owner's paired devices the way any other "needs you" moment does (push,
ADR 0011): the phone or the Capsule shows "Receive a move from
<name>(<fingerprint>)?", never requiring anyone at the destination's own screen. move.receive.answer { ask, decision }(HUMAN_ONLY, from any of the owner's paired devices, not necessarily the destination): a yes checked with presence on that device is what the destination accepts as consent, bound to this onemoveIdand the source's pinned fingerprint from section 2 - a decision on one ask never carries over to a later, different move, even from the same source. Only after a "yes" does the destination begin accepting the first chunk of any piece; a "no", or the ask expiring unanswered, refuses the move outright.- Without a live, answered
yesfor this exactmoveId, the destination's move endpoint refuses every write outright, the same "not available" shape the rest of Vyre uses for a refusal that must not leak why.
4. What the vault piece actually carries (reviewer's HIGH 3)
Applying ADR 0040's rule here: item secrets move; trust state never does. A vault is not one undifferentiated blob - splitting it wrong would either leave the person's passwords behind (data loss) or silently duplicate device trust onto a machine that was never the one enrolled for it (a real security hole, not a convenience). Three kinds, three different rules:
- Item secrets (passwords, API keys, notes - the things a person actually keeps in the vault
for their own recall): move, re-encrypted end to end (section 5). These are the only rows that
count toward the vault piece's bytes and counts in
move.plan. - Trust state (presence keys, the Capsule's own pin, sessions bound to a specific device,
pairing and device records for relay/link): never copied. Each is made fresh on the
destination, the same way it was made the first time (a new presence enrollment, a new Capsule
pairing, a device re-pairing itself to the new home).
move.planreports these separately from the vault's byte count, as a plain list of what will need setting up again, not folded into "N items moved." - Grants (which agent or project may reach which vault item): shown to the person on the destination for re-approval, one at a time or in a batch they can see, never inherited silently. A grant that existed on the source is a fact about the source's own vault; the destination's vault starts with none until the person says otherwise.
move.status's vault entry therefore carries three numbers, not one: moved (item secrets),
remade (trust-state pieces the person will redo, named), pending_grants (waiting for
re-approval on the destination).
5. Transport: introduce once over the relay, carry every byte over Tailscale
- Introduction. If the destination cannot be proven to be the owner's own node (section 2),
move.planrefuses withnot_reachableand points at tailnet's own join flow: the relay's QR pairing and Noise handshake establish who the destination is and put it on the same tailnet. The move engine does not reimplement pairing, trust, or Noise - that is tailnet's and relay's contract, not this one's. - A fresh handshake for the move itself (reviewer's MEDIUM). The introduction that first paired
the two nodes may be weeks old. Every per-item session key (section 6) comes from a handshake
run fresh at
move.starttime, between the two pinned static keys from section 2 - never the original introduction's own key, however it was derived, reused stale. - Carrying. The actual copy is a direct, tailnet-only connection between the two vyreds, the
same trust model
core/link/transport.jsalready gives the Mac-to-box pairing (peer looked up bytailscale whoisbefore a byte is sent, then checked against the pinned pairing record from section 2, not trusted from whois alone - the Wink tagging problem again). That file is scoped to exactly one pairing shape (a Mac client, a box server) today; this needs the same guarantee between any two Vyre nodes, source-initiated. Needs from tailnet: amove-flavoured open (or a generalisedtransport.openthat takes either side's role) beforemove.startcan build on it. Flagged, not assumed. - Never over the relay's bridge. The relay stays what it has always been: introduction,
presence, small control messages. A move that has not yet joined the tailnet does not fall back
to relaying its bytes through it; it waits, and says why (
not_reachable). - Implementation note, not yet resolved: on a Mac running vyre-core (ADR 0040 phase 2), the vault is exported by core, not vyred. The move engine's vault piece will need to call into whichever process actually holds the vault on that machine, not assume vyred always does; flagged here so the build doesn't wire the vault piece straight into vyred on every platform by default.
6. Vault item secrets: re-encrypted end to end, never unsealed on disk
Mirrors what core/vault/backup.js already does for a manual export/restore (see its own header:
"restoring re-seals every item under the master key of the vault it lands in, through the same
put() every other item goes through"), minus the passphrase step, which is for a cold file a
person carries by hand, not a live device that just joined the tailnet:
- On the source, one item secret at a time: read and decrypt it (as any use of the vault already does), encrypt it under the fresh, move-specific session key (section 5), send it, and discard the decrypted bytes from memory immediately after.
- On the destination: decrypt from the session key, then re-seal at once under the destination's
own master key through its own
put()- the same call path every other item takes getting into a vault. The item is in the clear only in a variable between those two decrypt/encrypt steps, on each side, never written to a temp file, never logged. - Trust state (section 4) never reaches this path at all: it is not read from the source's vault for a move in the first place.
- An item secret that already lives at the destination (say, a re-run after a partial failure) is
compared by its own integrity check, not re-sent if unchanged -
move.status's resumability (below) applies to the vault piece the same as any other.
7. The tools
move.plan { destination } (HUMAN_ONLY): a dry run against a destination proven to be the owner's
own (section 2). Counts and bytes per piece (projects, memory, vault's item secrets only, sessions),
the vault's separate remade/pending_grants lists (section 4), and a planId - a hash of the
four pieces' current state, so a stale plan can never be started against (constraint 3). No write,
nothing moved, nothing on the source touched.
move.start { planId } (HUMAN_ONLY, on the source): checks the destination's mutual pinned-identity
(section 2), then raises move.receive.ask on it rather than sending anything yet (section 3).
Returns a moveId and { stage: "awaiting_receive" }. Refuses a stale planId (the source changed
since the plan) or a destination that fails the mutual identity check, rather than starting
against a wrong count or an unproven machine.
move.receive.answer { ask, decision } (HUMAN_ONLY, from any of the owner's paired devices):
section 3's consent. A yes checked with presence unlocks this one moveId for this one source's
pinned fingerprint; copying begins only after it. A no, or the ask expiring unanswered, ends the
move at stage: "failed" with a plain reason, nothing sent.
move.status { moveId } (owner-only, reviewer's LOW): { stage: "awaiting_receive"|"copying"| "verifying"|"ready"|"confirmed"|"failed", pieces: { projects: { bytes, of, done, error }, memory: {...}, vault: { moved, of, done, error, remade: [...], pending_grants: [...] }, sessions: {...} } }. Resumable: a status check after a restart of either machine picks up where
copying left off, never re-starts a finished piece (or an item secret already verified present).
move.confirm { moveId } (HUMAN_ONLY): the person's go-ahead once stage: "ready" - every piece
copied, and the destination has verified its own checksums, item secrets included. Marks the move
confirmed and emits move.confirmed { moveId }; anywhere's onboard.machine is what actually
flips config.machine on the source (this engine only reports the fact, never calls it directly -
that boundary belongs to anywhere, per the contract already agreed). This step never deletes
anything on the source (constraints 4 and 5): the source is still fully itself, just no longer the
one config.machine points a fresh session at.
move.cancel { moveId } (PERSON_ONLY, instant): stops an in-flight or ready-but-unconfirmed move.
The source was never touched, so this is cleanup of the partial destination copy only.
move.free { moveId } (HUMAN_ONLY, only after stage: "confirmed"): constraint 5's own action,
entirely separate from move.confirm. move.free.preview { moveId } first (bytes reclaimed per
piece, same shape as the plan); move.free itself re-checks the destination live (reviewer's LOW:
refuses unless the destination has checked back recently and its checksums still hold, not merely
what they were at confirm time) before it deletes the source's own copy of the four pieces - a
second, distinct gate from the confirm above, on purpose (a person who confirmed a move because the
new machine looked fine, then found a gap a day later, must still have the old one).
8. Events
move.receive.ask { moveId, source } (raised on the destination, delivered by push to the owner's
paired devices like any other "needs you" ask, ADR 0011), move.receive.answered { moveId, decision }, move.progress { moveId, piece, bytes, of } (throttled, not per chunk),
move.piece.done { moveId, piece }, move.failed { moveId, piece, error } (retried by a fresh
move.start against the same planId, not a special recovery path), move.confirmed { moveId },
move.freed { moveId }.
9. Checksums, not trust
Every piece's checksum is verified on the destination before stage reaches "ready"; vault item
secrets are verified by re-sealing successfully and reading them back once, not merely by the
bytes arriving. A piece whose checksum fails is retried, never surfaced to the person as "ready"
with a silent mismatch. move.free repeats this check live (section 7) rather than trusting the
plan-time result.
Open question, sent to anywhere
Whether vault and sessions need to move atomically together (a session mid-thread holding a vault-derived credential) or each piece can lag independently as above - the four-piece split otherwise fits the engine's real constraints (each piece copies and verifies on its own, no cross-piece ordering needed except the final confirm gate, and now the free gate after it).
Needs from others
- tailnet: which pairing table is the authoritative "this node is the owner's own" record (section
2), and the generalised, source-initiated transport open (section 5) -
core/link/transport.jstoday is scoped to the Mac-to-box pairing shape only. - anywhere: confirms
onboard.machineis the only thing that flipsconfig.machine, listening formove.confirmed(already the agreed boundary, restated here since this is the first ADR to spell out the free step too). - relay/tailnet: confirms
move.plan'snot_reachablerefusal is the right seam for "not joined yet, or not provably the owner's own", rather than this engine growing its own identity path. - vyre-core (ADR 0040 phase 2): whoever owns the Mac's core process, for the vault export call the move engine's vault piece will need to make there instead of assuming vyred holds the vault.
- push (ADR 0011):
move.receive.askis delivered the same way any other "needs you" moment is; confirms a move-shaped ask fits its existing kinds, or needs a new one.
Consequences
A move never has a moment where a vault item secret sits decrypted anywhere but a live process's memory, on either side, and it never copies the trust that made the source's vault trustworthy in the first place - that trust is remade fresh on the destination, with the person's own eyes on every grant. A destination never receives anything the person did not consent to, from wherever they actually are - a headless server never needs someone standing in front of it. A person can walk away mid-copy, come back, and either resume or cancel with the source exactly as it was. Freeing the old machine's disk is never bundled into the same decision as trusting the new one, and it is checked live, not assumed, before it happens.