Decision 0054
54. The data leg: a restore is a request the next boot honours
Architecture decision record 0054: 54. The data leg: a restore is a request the next boot honours
Refines: ADR-0006, ADR-0012, ADR-0036
Context
ADR-0006 says the rollback claim is three-way and honest: system by generation, data by a
Btrfs snapshot of /persist taken before any data-touching transaction, external never. Two of
those three were true. The keeper took the snapshots and journalled them; the stem had
SnapshotTake and, in the same doc comment, a note that restoring from one “is not here and is
deliberately not here”.
That note was right about the difficulty and wrong to leave it there. A machine that takes snapshots it cannot put back is not offering a rollback, it is spending disk on the feeling of one, and the honest version of “we have not written it” is a task list, not a shipped promise.
Two things make the restore harder than the taking, and both of them shape what is below.
The first is that a machine cannot replace the filesystem it is running on. /persist is mounted,
/home is mounted, services have files open on both. There is no moment during normal operation
when a subvolume can be swapped.
The second is subtler and is the one that would have made a naive implementation destructive.
Snapshots lived at /persist/.snapshots, which is a directory inside the @persist subvolume.
Putting /persist back means deleting the subvolume it is — and every snapshot inside it would go
with it. Restoring one snapshot would have destroyed all the others. Snapshots of a thing cannot
live in the thing.
Prior decisions
ADR-0006 promises the three legs. ADR-0012 chose Btrfs subvolumes and impermanence. ADR-0036 established the mechanism this reuses: the root is thrown away at every boot and made again from a blank snapshot, by a unit in the initrd, at the one moment nothing is mounted. ADR-0004 puts privileged acts in the stem and authority in the keeper.
Decision
@snapshots is a subvolume of its own, mounted at /persist/.snapshots. The path does not
change, so nothing that already writes there moves; what changes is that a restore of /persist
no longer deletes the records of every other. It is deliberately not in SUBVOLUMES — that list
is what the keeper snapshots and what a restore may name, and this is neither. It is the one
subvolume a restore never replaces, which is also why the staged request lives on it.
A restore is a request, and the next boot is what honours it. Operation::SnapshotRestore
destroys nothing: it checks that the snapshot is there — refusing now, to somebody who can be
told, rather than at a boot that would find out and have nobody to tell — and writes one line to
/persist/.snapshots/.restore: the subvolume’s own name, a space, the snapshot’s. The subvolume by
@home rather than by /home, because the reader is an initrd and at the moment it reads there
are no mount points.
boot.initrd.systemd.services.tacid-restore-data reads it beside the unit ADR-0036 added, with the
top of the filesystem mounted and nothing else. It makes the new subvolume first, renames the old
one aside, renames the new one into place, removes the request, and only then deletes the old — so
a machine that loses power in the middle comes back with both subvolumes on the disk rather than
neither. It validates the name in the file against its own list rather than trusting it: the stem
wrote it, but by the time it is read it is a file on a disk, and the only thing between a wrong
word in it and a deleted /nix is that list.
What may be put back is narrower than what is snapshotted. /persist, /var/log, /home.
Not /: an ephemeral root is already made again from the blank at every boot, so restoring it
would mean putting back something the next boot throws away. Not /nix: the store is what every
generation is made of, and putting it back to an older one would strand every generation built
since — the system leg is a generation switch, which is a better mechanism for the same wish.
The gate is the keeper’s, not the stem’s, and the reason is what a guard means. The stem’s
guarded operations require owner_present, which is set by a physical prompt and by nothing else;
they are the things a live image’s installer does at a console. A running machine has no physical
prompt, so marking this guarded would have made it an operation nobody could ever ask for — not a
safer machine, a machine that promises a rollback and refuses it. So the guard is the same one
consent goes through: somebody who could approve a change asks, the journal records who, and the
person is told what they are about to lose. That is the standard Operation::Restore already
holds, which puts the system back and is likewise not guarded.
tacid data is the surface: list (newest first, each saying what it is a snapshot of),
snapshot (take one now — the machine takes its own before any change of its own that touches
data; this is for the other case, a person about to do something the machine is not doing for
them), and restore <name>, which without --yes prints what it would cost and changes nothing.
The words are the words of what is lost: “put /home back to Tuesday” sounds like an addition, and
it is the deletion of everything since Tuesday.
Consequences
-
The stem could not see the one subvolume this matters most for. Its unit set
ProtectHome = true, which mounts an empty tmpfs over/homeinside its own namespace, and/homeis inSUBVOLUMES— sobtrfs subvolume snapshotof a person’s home answered “Not a Btrfs filesystem” and the keeper refused the change that asked for it. Every mode bit and every line of Rust was correct; a machine had to be booted and asked for a snapshot to find it. It isread-onlynow, which is exactly what a snapshot needs and keeps the promise the setting was making: the root process cannot change anybody’s files. The class is a static test —the_stem_can_see_every_subvolume_it_is_asked_to_snapshotreads the unit’s own text and asks whether anything in it hides a mount the stem is asked to snapshot. -
The three-way claim is true. A generation switch puts the system back and this puts the data back, and they are separate because they go wrong separately: a machine can want yesterday’s configuration and today’s files, or the other way round.
-
A restore costs a reboot, and every surface says so before and after rather than reporting success on a machine that has not been touched.
RestoreResult.reboot_neededexists to make that impossible to render as “restored”. -
The disk layout changed, so a machine installed before this has no
@snapshotssubvolume. Pre-1.0, with no installed machines outside the test suite, that is a layout change and not a migration; if that stops being true it becomes one, and the note goes here. -
It is proven on a machine, in
checks.*.install: a file is written, a snapshot taken, the file changed, a restore asked for, and the machine rebooted twice — once to find the file as the snapshot says it was and everything written since gone, and once more to find that a restore happens once rather than at every boot after it. There is no way to stand in for this in a unit test, because the act happens in an initrd. -
/persist/.snapshotsis notneededForBoot. Nothing is bound from it, and the initrd reaches it at the top of the filesystem anyway.
Alternatives rejected
- Swap the subvolume on the running machine. It cannot be done, and the versions that look like they work — unmount, remount, restart the services — leave a machine half in one world and half in the other, which is the state every other decision here exists to prevent.
- Keep the snapshots inside
@persistand copy files out of them instead of swapping subvolumes. A file-level restore is a different, weaker promise: it cannot delete what was added since without walking the whole tree, and “put it back” that leaves strangers behind is the kind of half-truth this project is meant not to ship. - Guard it at the stem. Correct in spirit and unusable in fact, for the reason above. Making
the guard mean less so that this could pass it would have been worse:
owner_presentis load- bearing for disk layout and encryption keys, and a flag that sometimes means “a person is here” and sometimes means “a socket said so” protects nothing. - Fold it into
tacid undo. Tempting, because undo already moves the genome and the generation together. But undo is reversible by construction and this is not, undo takes effect now and this takes effect at a reboot, and a single command with two halves that differ on both counts is a command whose consequences nobody can predict.