TacidOS
Developers
Documentation

Decision 0040

Generations are kept by declaration and collected by the keeper

Architecture decision record 0040: Generations are kept by declaration and collected by the keeper

Status
accepted
Date
2026-09-05

Context

ADR-0013 decided what a machine keeps: the last ten generations, everything from the last thirty days, and anything pinned, with the store collected weekly at a quiet point. It did not decide who applies the rule, and the stem carried a collect-garbage operation that applied a rule of its own — nix-collect-garbage --delete-older-than — which forgets the old generations of every profile on the machine, keeps no count, knows no pin, and runs inside a confinement that cannot write the profile or the store. Nothing called it. A machine that updates itself hourly and never forgets is a machine whose disk is a countdown, and one whose retention is a sentence in a plan is a machine with no retention.

The question is where the rule lives. Three places were possible: the timer that asks for a collection, the stem that carries it out, or the keeper between them.

Prior decisions

ADR-0004 gives the keeper the immune system and puts it outside the agent’s reach; ADR-0010 says the agent cannot write tacid.core.*; ADR-0033 makes the stem a set of typed operations laid out as fixed argument vectors, with no policy of its own; ADR-0038 has the update check propose and never decide.

Decision

The owner declares, the keeper decides, the stem forgets by number.

The owner declares what is kept, in the genome, where the body’s keeping is:

tacid.core.retention = {
  count = 10;          # the newest so many, however old
  days = 30;           # everything younger, however many
  pinned = [ 12 ];     # by number, whatever their age
  collect = "weekly";  # when, as systemd reads a calendar; "" for never on a timer
};

It is rendered into the keeper’s policy, and the keeper reads it from there and from nowhere else: nothing that asks for a collection says what it keeps. A generation is kept if any one reason says so — it is running, it is what the machine booted, it is what the profile points at (the boot default, staged or not), it is pinned, it is among the newest count, or it is younger than days — and a generation whose age is not known is kept, because nothing is forgotten on a guess. The keeper answers generations.collect with what it forgot and why each of the rest stayed, and writes the same in the journal.

The stem’s operation names generations, never a rule: nix-env --delete-generations for the numbers it is given, nix-store --gc, and then the profile’s own switch-to-configuration boot, which writes the bootloader’s entries again from what is left. The profile’s generation rather than the running one, so that a revision staged for the next boot stays the default. Each of the three writes where the stem’s confinement does not reach, so each runs as an activation does: as a transient unit of its own, waited for, named. nix-env refuses to forget the generation the profile points at, which is the one guard the stem keeps for itself besides refusing a generation 0.

A timer the owner sets runs tacid-collect, which asks the keeper when nobody has a session on the machine and otherwise says “not now” and lets the timer come back. A full disk is a reason to collect and not a reason to wait, so that is the only quietness asked for. tacid collect asks the same way, and tacid generations shows the numbers a pin names. Models are not store paths and are never collected.

Consequences

  • The agent cannot loosen retention: the options are under tacid.core, which the policy engine refuses from an agent principal, and the keeper reads its own policy rather than the caller’s request.
  • A machine keeps what it runs, what it booted and what it will boot, whatever the numbers say; an owner who sets count = 1 and days = 0 keeps those three and nothing else.
  • The stem lost its only rule. Garbage { keep_days, keep_count } became Garbage { generations }, and what it carries out is what the keeper wrote down.
  • The lifecycle test runs the timer’s service on a machine with no system profile and finds the keeper answering by its policy and the journal saying so; the forgetting of real generations — a channel’s abandoned revision among them — is set out for the channel test, whose follower has three, and this record will say what that run found.
  • system.generations gained default and made_ms, which is what the decision needs and what a person reading tacid generations wants: which one the machine will boot, and how old each is.

Alternatives rejected

  • The stem keeps a rule (--delete-older-than). A process running as root should carry out what it was told and nothing it assumed; a rule in the stem is a policy nobody declared and nobody can pin against, and nix-collect-garbage applies it to every profile on the machine, a person’s included.
  • The timer’s service says what to keep. It runs as the agent’s user, and so would the agent asking. A retention that arrives with the request is a retention the requester chose.
  • A generation’s age from the store path. Store paths have no time; the profile’s link does, and it is what nix-env itself counts by.

The second leg: a snapshot before a change that touches data

A generation puts the system back. It does not put back a file the change rewrote, and ADR-0006 promises rollback is three-way — system by generation, data by a snapshot of the declared roots, external never. The second leg did not exist.

The classifier was honest about it, which was the right answer and not the answer: a write inside a declared snapshot root was called irreversible, because is_snapshotted asked whether anything took a snapshot and nothing did. So every write asked the owner, and the reason it gave — “writing ~/projects/a.rs, which no snapshot covers” — was true.

Now the keeper takes one. Before it hands anything to the stem, if the classification says the change touches data, it asks for a read-only Btrfs snapshot of every declared root that is a subvolume this machine keeps, named for the transaction, and writes down which it took. Then it activates. The order is the whole of it: a snapshot taken afterwards is a snapshot of the damage.

Three decisions worth their reasons.

The stem takes it, and takes only what the layout keeps. Operation::SnapshotTake names a subvolume by its mount point and must match one of SUBVOLUMES; a stem that snapshotted any directory it was handed would be a run(string) spelled differently. The name is one path segment, because a name with a slash in it is a place, and choosing places under /persist is not something a caller does through this door.

It is not guarded. A snapshot adds and destroys nothing; the worst it can do is use space. Requiring a person at the machine would mean no unattended change could ever be undone, which is a machine promising a rollback it will not perform. Restoring from a snapshot is a different operation — it destroys what is on the subvolume now — and is deliberately not in the stem’s vocabulary yet.

A snapshot that could not be taken stops the change. The classifier is about to be allowed to call the change reversible on the strength of that snapshot. Proceeding without one means telling an owner their file can be put back when it cannot, which is the failure this exists to prevent arriving through the fix for it.

And where the promise is made: not in the policy, which cannot assert a fact about the machine, and not in the classifier, which would then promise it everywhere including in the tests of the promise. Keeper::new builds its engine with taking_snapshots, because the keeper is the thing that asks for one.

The snapshots live at /persist/.snapshots. Under /var they would be on the ephemeral root, which the initrd throws away at every boot — a rollback that does not survive a reboot is not one.