TacidOS
Developers
Documentation

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

Status
accepted
Date
2026-09-06

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 /home inside its own namespace, and /home is in SUBVOLUMES — so btrfs subvolume snapshot of 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 is read-only now, 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_snapshot reads 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_needed exists to make that impossible to render as “restored”.

  • The disk layout changed, so a machine installed before this has no @snapshots subvolume. 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/.snapshots is not neededForBoot. 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 @persist and 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_present is 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.