TacidOS
DevelopersGitHub

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.