ArchitectureDraft
ADR 0039: Vyre anywhere
Status: proposed, 28 Sep 2026. Supersedes the "server means a Linux box" assumption in SPEC and
in core/config's defaults(), which picked role from process.platform alone.
Context
User decision, 28 Sep 2026: "Vyre anywhere." Three shapes, one install:
- Solo: one computer (Mac first, then Windows) runs everything. No Tailscale.
- Your own always-on computer as the server: a Mac mini or a Linux box.
- A cloud server.
Today core/config/index.js decides role at defaults() time from the OS: darwin gets
"local", anything else gets "box". Eight modules (releases at core/apps, computers,
glass, hooks, names, network, onboard, relay) declare "roles": ["box"] in their
manifest and core/modules/index.js loads them only when config.role === "box". That made
sense while "box" meant "the Linux container the install script provisions": there was no
other way to be a server. It stops making sense the moment a Mac can be the server too: a person
running Solo on a MacBook, or a Mac mini as an always-on server, needs those eight modules, and
today's code has no way to give them.
The role also isn't a choice today. It's inferred once, silently, from process.platform, and
never revisited. "Move to server": connect a real box later, or move an existing Solo setup's
projects/memory/vault/sessions onto one: has no owner and no flow; core/projects/move.js
exists but only migrates a projects folder inside one box's own volumes (ADR shipped for
box-deploy's homes migration), not "this Mac was solo, now a box is the server and the Mac
becomes a device."
Decision
1. The choice is a new field, config.machine, not a rewrite of config.role
A first pass tried replacing config.role's two values with three ("solo"|"server"|"device").
That breaks on contact: about fifteen files outside this ADR's ownership: core/planner,
core/term, core/projects, core/statusline, core/link, core/files (and drop.js,
drive.js), core/vault/watch.js, core/sessions/config.js, core/modules/federate.js, and
several core/cli/commands/* (link, assistant, up, phone, doctor): read
ctx.config.role === "box" or "local" directly, not through a contract, because core/config
is the kernel and every part is allowed to import it. Renaming the value space silently breaks
every one of them; rewriting all fifteen unreviewed, in one pass, owned by other teams, is
exactly the repo-wide change this ADR is supposed to avoid.
Instead: config.role keeps its old two values and its old OS-guessed default, untouched.
config.machine is a new, additive field: the person's actual choice:
"server": this machine is the always-on server. Serves the Deck, runs watchers, owns the vault, the box-only modules run here."device": this machine is a device of a server elsewhere (another Mac, a Linux box, or a cloud VM). The box-only modules stay off; local-only modules (Capsule, voice, screen-mac, hands-mac) run here as before."solo": this one machine is both, but is a server to no one until the person says so. It runs the full local core and none of the eight box-only modules; nothing needs Tailscale because there is no second machine yet. Fixed 28 Sep after reviewer's HOLD on 80fd866e: a first pass made solo bothisServerandisDevice, which put the tailnet listener, public webhooks, the relay and the owner-claim flow on every existing Mac by default, with no choice made.isServer("solo")is nowfalse; only"server"is a server. Solo becomes one only by runningvyre server hereor pairing a second device (section 5).
defaults() computes machine the same way role always was (darwin → "solo", else →
"server"), so a fresh install's behavior doesn't change until the person actually chooses
otherwise (onboarding, vyre server here, or pairing a second device). An old config.json with
an explicit role but no machine migrates once: role: "box" implies machine: "server"
(someone who set that by hand meant a real server); role: "local" implies machine: "solo".
core/modules/index.js's loader keeps its two-bucket manifest vocabulary ("roles": ["box"],
["local"], ["box","local"]) unchanged: forty-plus manifests across every team's modules
already speak it: and now reads config.machine, not config.role, to decide which buckets
are active (roleBuckets()):
| config.machine | loads "box" modules | loads "local" modules |
|---|---|---|
server | yes | yes on darwin, no on Linux (team-lead, 28 Sep: a Mac chosen as the server is still, often, someone's own desk -- Capsule, voice and the rest of the local core stay; a Linux box never had them anyway) |
device | no | yes |
solo | no | yes |
Two new kernel helpers, config.isServer(machine) and config.isDevice(machine), also accept
the legacy role strings "box"/"local" as aliases, so a caller that hasn't moved to
machine yet (a test, or one of the fifteen files above, if it later needs the third state)
gets the right answer either way. core/daemon/index.js now builds the module registry and
Presence with cfg.machine; the three places that gated real behavior on "is this the server"
rather than a form-following-function role value: core/presence/index.js,
core/presence/module.js, core/onboard/index.js: were moved from role === "box" to
isServer(machine) because their behavior (restricting code-based passkey enrollment to the
owner's tailnet device; the "is this the box" branch in onboard's session catalogue) is exactly
the server/not-server distinction ADR 0039 exists to make explicit, not an OS artifact.
The other fifteen files' role-based branches (file roots, term roots, statusline wording,
vault sleep/lock detection, link's box-vs-Mac protocol, CLI command framing) stay exactly as
they are. Most of them encode a real, per-consumer decision about what "solo" should look like
that this ADR does not make unilaterally for every owner: e.g. should a Solo Mac's core/term
default to /work-style roots or the home directory? That is a question for core/term's
owner, informed by this ADR, not a string swap. Each such file keeps working today and gets its
own machine-aware pass, owner by owner, tracked under "Next" in team/archive/work-journals/anywhere.md: this
ADR unblocks that work without forcing it into one unreviewed commit.
computers and glass (already "roles": ["box"]) stay additionally gated on Docker being
present, unchanged from today: a Mac server with no Docker still runs onboard, names,
network, hooks, relay, releases, just not agent computers or Glass.
Nothing in this ADR asks any other team to rename a manifest field. "Server" and "device" are
the words the person and the UI use; "box" and "local" stay the words the module loader and
forty manifests use internally. docs/GLOSSARY.md (owned by docs, ADR 0038) gets the mapping so
new copy uses the right word without archaeology.
2. Migration for existing installs
config.machine is new, so there's nothing to migrate away from: only a value to infer for an
existing config.json that has no machine key yet, per section 1: defaults() gives it the
same OS guess role always used (darwin → "solo", else → "server"), unless the person
already made an explicit choice by hand (role: "box" or role: "local" in the file, with no
machine), in which case that choice wins: "box" → "server", "local" → "solo": over
the OS guess, since setting role by hand said more than the platform does. Nothing is written
back to disk by this inference; load() computes it fresh every time, same as it always has for
role, and only core/config/save() (onboarding, vyre server here, the move flow) persists a
real choice.
3. The Mac as a server: a real service, not a session
Superseded in its mechanics by ADR 0040 (drafted 28 Sep with e2e), kept here for the
capability it still names. The section below assumed vyred itself (the person-side daemon,
sharing the person's uid) could be made to survive logout, sleep and reboot directly. ADR 0040's
trusted-root split changes who runs what: vyre-core, a root-owned process the person's own uid
cannot write to, is what must survive logout/reboot and hold anything privileged; person-side
vyred stays a thin, forgeable client. vyre server here's actual install mechanics (the admin
password, the LaunchDaemon, the signed-update story) now live in ADR 0040. What stays true here:
a Mac chosen as "server" or "solo" still needs something always-on and reachable, machine
still flips to "server", and Solo's own path is unaffected until it becomes a server.
A Mac chosen as "server" or "solo" needs something on it to survive logout, sleep and reboot
the way the box's container does. vyre server here (new CLI) does on a Mac what the install
script does on Linux, built out fully in ADR 0040:
- Sets
config.machineto"server"(or"solo"if no device will ever join: the person is asked, matching the capability ladder indocs/design/anywhere.md). - Installs a
launchdLaunchAgent(com.vyre.vyred.plist,RunAtLoad+KeepAlive) so vyred restarts after a crash and after every login, and a caffeinate-backed keep-awake (pmsetassertion, not preventing display sleep: only idle system sleep) so the Mac stays reachable without the person's screen staying lit. Neither survives a reboot with FileVault requiring a login before disk unlock without the person's own login item, which the command also adds if the person agrees. This is a LaunchAgent, not a LaunchDaemon: a LaunchDaemon can't reach an unencrypted-at-that-point-yet home volume before FileVault unlock anyway. - Refuses on a Mac already
"device"of a running server without--force, and warns if Energy Saver / Battery settings will let the Mac sleep regardless (best-effort detection only; this ADR does not open System Settings for the person).
Undo: vyre server here --undo removes the LaunchAgent and login item and leaves config.machine
alone (a person moving off Mac-as-server chooses the new machine through the move flow, not this
command).
4. Move-to-server is one flow, used twice
The same flow serves onboarding's "I already have a server, connect it" and a later "move my Solo setup to a server":
- Point at a server. The person names an existing server (its Tailscale name, or pastes a setup code an already-running server shows) or picks "make this Mac the server" (step 3 above) or "spin up a cloud server" (out of scope here: tailnet/launch own the join UI).
- federation's move engine copies what moves: projects, memory (facts, transcripts index), vault (re-wrapped to the destination's device key, never the plaintext secrets themselves in transit unencrypted), and sessions (the Claude Code sign-in and any in-flight Vyre-owned session state) from the source to the destination. The source stays untouched and running until the destination confirms every piece landed and re-decrypts.
- The source becomes a device. Once confirmed, the source's
config.machineflips to"device"(or is left"solo"a moment longer if the person is only testing: nothing forces the flip until they confirm in the UI). Box-only modules on the source stop on the nextvyredrestart; local-only ones keep running. - Nothing is ever deleted on the source automatically (user rule, 28 Sep: nothing that came
from a device is deleted without the person's explicit, previewed confirmation). The moved
copy on the source sits under
~/.vyre/moved-<date>/(mirroringcore/projects/move.js'sMOVED_RECORDpattern) indefinitely. Settings > Server offers "Free up space on this laptop", previewing exactly what would go (counts, by piece: projects, memory, vault, sessions) before the person confirms; there is no automatic or timed cleanup.
Failure and undo:
- A move that fails partway (network drop, destination out of disk) leaves the source as
"server"still, fully working: the flip in step 3 only happens after the destination confirms, so a failed move is a no-op from the person's side, just a retry. - "Free up space on this laptop" is available as soon as the destination is confirmed current, but never runs itself: the person previews and confirms every time, no matter how long it's been.
- The Deck's Settings > Server panel (launch owns the UI, this ADR owns what it calls) shows the move's live progress, and a red state if the source and destination ever disagree on which one is authoritative: never silently.
5. Solo plus a phone needs no move
Pairing a second device to a Solo machine (Tailscale, or the relay's QR pairing) is the capability
ladder's rung 2, not a move: nothing relocates, because the Solo machine's own data is already
where it should end up. On the first device to successfully pair, config.machine on that
one machine flips from "solo" to "server" (still the same files, same vault, same
vyred); the new device is simply "device". Tailscale or the relay turns on right then, not
before: a Solo person who never pairs anything never sees either. tailnet's onboard.join tool
(agreed 28 Sep, see team/archive/work-journals/anywhere.md) is exactly this trigger: its verify step, once a
device is confirmed reachable, is what flips the source's machine. federation's move engine
(section 4) is not called: there is nothing to copy.
6. Where this plugs in
- Onboarding (launch): the "how will Vyre run" step becomes the role choice from section 1 (three cards: Solo, another computer, a cloud server), not a Tailscale prompt. "I have a server, connect it" reuses the move flow, pointed at step 1. Solo needs no call at all until a device later joins (section 5).
- Settings (launch, native-core): a Server panel: today's role, "Move to server"/"Move off
this server" (section 4), "Free up space on this laptop" (section 4's cleanup, always
previewed, never automatic), and (on a Mac server) the
launchd/keep-awake status from section 3. - Windows (windows team): Solo on Windows needs an equivalent to section 3 (a Windows
service instead of
launchd) and its own module-loading story where"local"-only modules assume macOS today (local/screen-mac,local/hands-mac): out of scope for this ADR beyond naming the seam:core/modules/index.js'sroleBuckets()doesn't care what OS"device"or"solo"runs on, only thelocal/*modules do.
Build plan
core/config:config.machine, the migration inload(),core/modules/index.js'sroleBuckets()mapping, tests -- shipped, sha 80fd866e. (anywhere)- The eight modules' manifests stay
"roles": ["box"]unchanged; verify each one actually starts clean on macOS (some assume a Linux path or a container network today: audit before claiming "runs on macOS"). (anywhere) vyre server here/--undo, thelaunchdplist, keep-awake. (anywhere)- The move engine. (federation, this ADR names the contract in section 4)
- Tailscale/relay wiring on join. (tailnet)
- Onboarding and Settings UI. (launch)
- Windows Solo. (windows)
Open questions
- Resolved 28 Sep (team-lead): Solo plus a phone is section 5, not a move: Tailscale/relay turn on only once a device actually joins, never before.
releases(Android APK signing) needs the owner's signing key in the vault; on a Mac server with no Docker, does APK CI still reach it over the tailnet the same way? Needs a check againstcore/apps/releases.js's assumptions, not just the manifest gate.