| 1 |
# Continuity |
| 2 |
|
| 3 |
State continuity across machines is a first-class Alloy value. An Alloy install should be *rejoinable*, not just installable: flash the image, sign in to two things, and your files and your network appear. That is a different pitch from "runs your programs fast," and it is the right one for a personal machine that will be reinstalled, replaced, or added-to over time. |
| 4 |
|
| 5 |
This document collects the picks that carry that pitch. Companion to [STACK.md](STACK.md) and [CONSOLE.md](CONSOLE.md). |
| 6 |
|
| 7 |
## Thesis |
| 8 |
|
| 9 |
The machine is disposable. The state is not. What survives a reinstall or a hardware upgrade should not depend on the user remembering to copy files, re-establish keys, or reconfigure a mesh. Alloy ships the two adopted tools that make that true (a private network and file synchronization) as base-image citizens, gated behind an explicit enrollment gesture and controlled through the Alloy Console. |
| 10 |
|
| 11 |
Everything in this document is a curated adoption of existing tools. Alloy authors nothing in this space. The Alloy contribution is: the tools are present at first boot, they enroll in one screen, and they are controlled through the same design-system surface as everything else. |
| 12 |
|
| 13 |
## Tailscale |
| 14 |
|
| 15 |
**Pick: Tailscale client, shipped in the base image, disabled at first boot until the user enrolls.** |
| 16 |
|
| 17 |
- The client (`tailscaled` + `tailscale`) is open-source and works with either the tailscale.com control plane or a self-hosted Headscale server. |
| 18 |
- The control plane choice is exposed at enrollment time as a "Server" field, defaulting to tailscale.com. Users pointing at Headscale set `--login-server=<url>`, which Alloy handles as a first-class field in `alloy mesh` rather than a footnote. The console reads the control plane back and names it in the view title when it is self-hosted, so "which control plane am I on" is answerable without dropping to a shell. |
| 19 |
- Enabled via `alloy mesh` (see below) or plain `run0 tailscale up`. Not enrolled by default. An OS that phones home before the user says yes is out of character for Alloy. |
| 20 |
|
| 21 |
**Not shipping the Headscale server itself.** That is fleet-admin work, not client-OS work. Users self-hosting Headscale run it on their infrastructure of choice; Alloy points at it. |
| 22 |
|
| 23 |
**Not building an alternative.** WireGuard's userland ecosystem has several candidates (headscale-tools, netbird, innernet). None reach Tailscale's install-and-forget property. Alloy is not competing on this axis; it is adopting. |
| 24 |
|
| 25 |
## Syncthing |
| 26 |
|
| 27 |
**Pick: Syncthing user daemon, shipped in the base image, disabled at first boot until the user enrolls.** |
| 28 |
|
| 29 |
- Runs as a systemd user service (`syncthing.service`). Not root, not system-wide. |
| 30 |
- The daemon starts after `alloy sync` enrollment or an explicit `systemctl --user enable --now syncthing`. |
| 31 |
- Not enabled on-by-default with no folders configured; a running daemon with nothing to do is unjustified surface. |
| 32 |
- Web UI on `127.0.0.1:8384` remains reachable (Alloy does not disable it) but is not the recommended interface. `alloy sync` is. |
| 33 |
|
| 34 |
**Not shipping alternatives.** rsync-based sync is more mechanical and less mesh-shaped than Alloy wants; Resilio is proprietary; NextCloud/Seafile are servers, not sync tools. Syncthing is the honest pick for peer-to-peer file continuity. |
| 35 |
|
| 36 |
The image does carry the `rsync` binary, and that is not a reversal of the paragraph above. What is rejected here is rsync as the continuity model: a scripted push between two paths is not a mesh and does not survive a machine being away. The binary is in the base because fleet tooling shells out to it as a transport, which is a different job on a different schedule. Nothing in `alloy sync` calls it. |
| 37 |
|
| 38 |
## First-boot flow |
| 39 |
|
| 40 |
**Shipped as `alloy setup`.** At first login, one screen with two rows: |
| 41 |
|
| 42 |
``` |
| 43 |
+------------------------------------------------------------------------------+ |
| 44 |
| first boot | |
| 45 |
| two enrollments make this machine rejoinable. both are optional. | |
| 46 |
| | |
| 47 |
| > private network not enrolled reach your other machines from anywhere | |
| 48 |
| file sync not enrolled your files follow you onto this machine | |
| 49 |
| | |
| 50 |
| q when you are done. skipping is fine: alloy mesh and alloy sync enroll later.| |
| 51 |
+------------------------------------------------------------------------------+ |
| 52 |
``` |
| 53 |
|
| 54 |
Skip is fine and reversible, and the screen says so rather than leaving it to be discovered: skipping is leaving, not an answer to record. This is not a wizard, and Alloy does not grow one. Two rows, two actions, done. |
| 55 |
|
| 56 |
It authors nothing. Both rows act through the same `mesh` and `sync` backend seams the two views use, so there is no third enrollment path to keep in agreement with them. Each row reads its own backend and reports enrolled, not enrolled, or unknown; a backend that will not answer is still offered rather than going quiet, which is where this screen deliberately differs from `alloy mesh` (that one reads an unreadable status as enrolled, so a broken `tailscale status` cannot invite a signed-in user to sign in again; at first boot the likelier cause is a daemon that has not started, and the offer is the point). |
| 57 |
|
| 58 |
The mesh row inherits the terminal suspend, which is the one thing the shim could not simply delegate. `run0 tailscale up` escalates and then blocks on a browser login, so signing in tears the console down and rebuilds it, and the user is expected to come back for the *other* row. The shell refreshes a rebuilt view by calling its tick once, so the screen refreshes on the first tick after a suspend rather than on its five-tick poll counter; without that the row just enrolled would read "not enrolled" for several seconds after the sign-in that enrolled it. |
| 59 |
|
| 60 |
**Appearing once.** The session autostart (`etc/skel/.config/sway/config`) runs `alloy setup --if-first-boot`, and the console records that it asked under `first_boot` in the same SQLite store the theme preference lives in. The marker is written when the screen opens, not when it closes: an exit that never reaches the end, a closed lid or a killed terminal, would otherwise leave the machine asking again at every login, which is the behavior the screen exists to avoid. The cost of that choice is the reverse, a user who never looked at the screen it appeared on gets no second offer, and it is the cheaper mistake because both enrollments stay one command away. The key never replicates, deliberately: a synced copy would suppress the screen on the next machine the user sets up, which is exactly the machine that needs it. Typing `alloy setup` by hand always opens the screen; only the automatic invocation is gated. |
| 61 |
|
| 62 |
## Console subcommands |
| 63 |
|
| 64 |
`alloy mesh` and `alloy sync` live under Alloy Console. Full spec in [CONSOLE.md](CONSOLE.md); scope summary here so this document stands alone: |
| 65 |
|
| 66 |
- **`alloy mesh`** (was `alloy tail`; the old verb remains an alias). A ratatui front over Tailscale, named for what it is rather than who makes it, since Headscale users drive the same client. Replaces `tailscale status` as the daily-use surface. **Shipped:** peer list with online status and last-seen, this machine first, exit-node selection and clearing, the control plane named in the title when it is self-hosted, and enrollment. **Still to come:** MagicDNS lookup and share/unshare. |
| 67 |
|
| 68 |
A machine that has not joined a mesh gets an offer in place of the peer list, as `alloy sync` does, and `e` opens a one-field overlay for the control server before signing in. Empty joins tailscale.com; a URL joins a self-hosted Headscale, which is the first-class field this document asks for. The field is prefilled when the machine is already pointed at a self-hosted server, so a reconnection shows which mesh it is rejoining. |
| 69 |
|
| 70 |
Enrollment leaves the TUI, which is the one place this screen differs from `alloy sync`. `run0 tailscale up` needs root and then blocks printing a URL to open in a browser, so it goes out through the same suspend that hands `distrobox enter` a real terminal: the console tears down, polkit prompts on a clean screen, the link lands where the terminal's own selection works, and the console comes back into the peer list. Syncthing's enrollment needs none of that, which is why it runs in place. |
| 71 |
- **`alloy sync`**: a ratatui front over Syncthing, two tabs over one shell. Does not try to replicate the web UI's full feature surface, only the operations users perform; the web UI remains available for edge cases. **Shipped:** three tabs. Folders with path, share mode and paused state; devices with connection state and this machine first; and pending, the devices knocking. Pause and resume on either configured list, add and remove for both, accept for a pending device, and enrollment. Adding opens a small overlay of text fields (`a`); removing confirms first (`d`), and both confirms say what is *not* lost, because "remove folder" must not read as "delete my documents". The pending tab carries its count in its own label and in the status line on every tab, since an invitation nobody notices is the same as one that never arrived. **Still to come:** editing an existing folder's share list. |
| 72 |
|
| 73 |
**Accepting is possible; declining is not.** The pending tab is how a pairing finishes: the other machine adds this one, dials it, and waits. Accepting is an add with the id and name already known, and Syncthing drops the entry once the device is configured. There is no matching decline, because the REST API can drop a pending entry and `syncthing cli` does not expose that. So an unaccepted device stays listed. That asymmetry ships as-is and the view says so when `d` is pressed on that tab; a dismiss button that silently did nothing would be worse than not having one. Revisit if `syncthing cli` grows the verb. |
| 74 |
|
| 75 |
It fronts `syncthing cli` rather than the REST API directly. The API needs an HTTP client and an API key read out of a file the daemon owns; `syncthing cli` is a first-party client for that same API which finds the key itself, so the view keeps the command-front shape every other console screen has, and the log pane teaches a command the user could have typed. |
| 76 |
|
| 77 |
Enrollment is `systemctl --user enable --now syncthing.service`, in user scope, because the unit is a user service and the files are the user's. Nothing here asks for root, which is the whole difference from `alloy mesh`, whose `tailscale up` drives a system daemon. On an un-enrolled machine every read returns connection refused, and the view treats that as the state it exists to change rather than as an error: it renders the offer instead of a red line. |
| 78 |
|
| 79 |
## Non-goals |
| 80 |
|
| 81 |
- **Secrets sync.** Bitwarden, pass, KeePassXC, etc. Too personal, and the security model varies too much to have a defensible default. Users bring their own. |
| 82 |
- **Dotfiles sync as an opinionated setup.** Syncthing *can* carry `~/.config`, but Alloy does not ship a preconfigured dotfile-sync arrangement. Provide the daemon; let the user decide what to sync. |
| 83 |
- **Backup (partial):** See [Backup](#backup) below. Alloy ships restic in the image but declines to prescribe a policy. Sync is mesh + latest-wins; backup is encrypted-at-rest + versioned. Different trust models; the two must not conflate. |
| 84 |
- **Cross-user or multi-account state.** Alloy is a personal-machine story. Multi-user file sharing is a Syncthing feature the user can configure themselves; Alloy does not prescribe. |
| 85 |
|
| 86 |
## Honest costs |
| 87 |
|
| 88 |
- **Go in the base image.** Both tools are Go binaries. Alloy is Rust-forward but not Rust-exclusive (see [STACK.md](STACK.md) for other adopted C/Go tools). Naming the cost so it is not a surprise later. |
| 89 |
- **Two daemons at rest post-enrollment.** `tailscaled` (~30-50 MB RSS typical) and `syncthing` (~50-150 MB RSS depending on folder count). Acceptable for the value delivered; would be indefensible if unused, hence the opt-in gating. |
| 90 |
- **Tailscale control plane is proprietary.** Users who require a fully open control plane use Headscale. Alloy exposes the choice explicitly rather than pretending it does not exist. |
| 91 |
|
| 92 |
## Backup |
| 93 |
|
| 94 |
**restic.** Go, CLI-first, encrypted, deduplicating, snapshot-based. Broad backend support (local, SFTP, S3, B2, rclone-anything). Stable repo format. Widest tutorial coverage of any modern backup tool. In Fedora repos. |
| 95 |
|
| 96 |
**Shipped in the base image. No default policy.** Alloy provides the tool; the user provides the folders, schedule, retention, and destination. Sync (Tailscale, Syncthing) has defensible defaults because enroll-and-it-works is the whole model; backup does not: "where do you back up to" is answerable only per-user. |
| 97 |
|
| 98 |
The forcing observation: when a user's laptop dies, they have their sync mesh (Syncthing pulled files back) and their private network (Tailscale re-enrolls). What they don't have without configuring restic is *point-in-time recovery*, the "I deleted this file three weeks ago and just noticed" case that only versioned backup solves. |
| 99 |
|
| 100 |
**rustic, and why not yet.** rustic is a Rust reimplementation of restic that reads and writes the same repository format, which makes it the one place in the image where a competitive Rust option exists against an adopted Go tool. Principle 1 says that comparison gets made rather than skipped. Three things decide it for restic today, and none of them are about the code: |
| 101 |
|
| 102 |
- **Packaging.** restic is in Fedora main (0.19.0 as of this writing). rustic is in neither Fedora nor Terra, verified by repoquery against both, so shipping it means a `cargo install` stage in the Containerfile. That is the same cost that deferred wl-screenrec (see [STACK.md](STACK.md#screen-recorder-wl-screenrec)), and it buys less here: the console's `rust-build` stage already pays for the toolchain, but a backup tool nobody has configured yet is a thin reason to grow the build. |
| 103 |
- **Tutorial coverage.** The reason restic was picked stands. Backup is the one tool where a user searching in a panic needs the answer to already exist, and restic has a decade of write-ups behind it. |
| 104 |
- **Nothing is lost by waiting.** The repo format is shared. A user who starts on restic and later prefers rustic points it at the same repository; no migration, no re-seed, no lock-in. That is unusual and it makes this decision cheap to revisit. |
| 105 |
|
| 106 |
Revisit when rustic lands in Fedora or Terra, or when there is a second cargo-built binary to share a build stage with. The honest summary: restic is shipped for distribution reasons, not for technical superiority over rustic. |
| 107 |
|
| 108 |
Also rejected: borg (Python; historical repo-format transitions), kopia (GUI-first; adds complexity Alloy doesn't need), duplicity (older, less-featured), rclone (sync, not backup; no snapshot history or dedup). |
| 109 |
|
| 110 |
**Deferred: `alloy backup` subcommand.** A ratatui front for `restic snapshots` / `restic backup` / `restic restore` fits the Alloy Console pattern (see [CONSOLE.md](CONSOLE.md)). v2+ candidate, worth building once someone is using restic daily and wants a nicer surface. |
| 111 |
|
| 112 |
## Open questions |
| 113 |
|
| 114 |
- [ ] MagicDNS interaction with Fedora's `systemd-resolved`: verify no config conflict on a fresh Silverblue install with Tailscale enrolled. |
| 115 |
- [x] **Syncthing's `discosrv` and `relaysrv`: defaults for v1.** Public infrastructure is fine for the enroll-and-it-works model. Expose configuration in `alloy sync` only if requests appear from users self-hosting the discovery / relay side. |
| 116 |
- [ ] Headscale login-server field in `alloy mesh` enrollment: preserve across `tailscale down` / `tailscale up` cycles automatically, or require re-entry? Automatic is the correct default. Decided in favor of automatic and built that way: `ControlURL` lives in the client's own prefs, which is where the console already reads the control plane from for the title, so the field is prefilled rather than demanded. What is still unverified is the cycle itself. The claim rests on reading prefs on a machine that is already up, not on watching a `down`/`up` preserve them. Check on the image. |
| 117 |
- [x] **First-boot screen appears once.** If the user skips both Tailscale and Syncthing, `alloy mesh` and `alloy sync` are the enrollment paths after that; the first-boot flow does not re-appear. |
| 118 |
|