Decision 0037
The machine builds its next generation from what it carries
Architecture decision record 0037: The machine builds its next generation from what it carries
Context
Two stand-ins were left in the Phase 1 loop on purpose: the daemon’s Nix builder and the stem’s
effect on a system. Everything around them — the classifier, the keeper, the journal, the
health gate, the rollback — was the code that ships, proven on a booted machine; the two of
them were proven against a recording nix and a stem that wrote what it would have done. The
installation test had a machine that booted from a disk the stem laid out, with the sources it
was built from in its closure (ADR-0034),
and the promise of that ADR — a machine that changes itself with the network cable out — had
never been asked of it.
So the test asked. After the installed machine booted, it was told install hello, and the
daemon’s real nix build and the stem’s real switch-to-configuration ran, for the first time,
on a system that was itself their output. The first run built nothing: it set out to build the
compiler. This record is what stood between the promise and the generation, and what was
decided about each.
Findings
- A build does not write a lock file; a lock does. The daemon pinned the genome’s inputs
to the store paths the machine carries with
--override-input, as ADR-0034 said it would, and Nix built with them and then refused to write the lock it had computed: a build whose inputs were overridden on the command line does not record them, and says so in a warning the recordingnixhad never printed.nix flake lock --override-input …records them. So a first build of a genome without a lock is two steps: the daemon locks the inputs to what the machine carries, stages the lock file so the flake can see it, and then builds what the lock says. Every later build of that genome reads the lock. A genome that arrives with a lock — one the keeper wrote at installation, or onetacid updatemoved — is built as it is. - A NixOS system is assembled by tools no running system carries. Nix builds a derivation
only when every input’s output is in the store, and the store of an installed machine holds
the runtime closure of its system: what the system references, not what it was made
with. The system path is a
buildEnvfinished by the desktop-database updater,install-info, and agetconfpackage whose one binary the path links straight through to glibc; the D-Bus configuration isxsltprocover a catalogue hook; everywriteTextandrunCommandruns under the stdenv without a compiler; the unit directories are made withlndir; the initrd’s modules are shrunk withkmod’s tools and cleaned withnuke-refs; the wrappers are checked with the capability verifier. None of them is referenced by the finished system, so none of them survived the installation, and a machine asked to change one option went looking forinstall-info, found the derivation and not the output, and planned 1,278 builds from the bootstrap seed up, which is where it ran out of network. These tools are now declared assystem.extraDependenciesin the core module, named frombuildPackageswhere the NixOS modules name them from there, so the derivation the machine keeps is the very one the system path is built by. A machine that changes its declaration now assembles the change from what is on its disk: nineteen derivations, all of them configuration, none of them a package. - What was found was found by subtraction, not by trying. An hour of software-emulated Nix per attempt is no way to discover a missing tool at a time. The list above came from the host: evaluate the machine’s own genome with the same pinned inputs, take the derivation graph of the next generation, and walk it from the top against the installed system’s closure, marking every derivation whose output the machine does not have. What is left when nothing but configuration remains is the answer, and it was the answer before the machine was asked again. The method is worth more than the list; the list will change with nixpkgs, and the analysis is how the next change is found in minutes.
- The machine’s evaluation must be the host’s, derivation for derivation. The installation
test builds the system it hands the installer on the host; the machine’s own genome then
evaluates the same declaration inside the VM. If those two evaluations differ by one module,
the machine’s first generation is not the one it booted,
tacid undocannot bring the booted one back, and everything the booted closure carries is beside the point. They differed twice. The test had been handing over the test framework’s node, whose evaluation carries the driver’s own defaults, and a system the genome would never evaluate to; now the system installed is the genome composed through the samelib.machineits own flake calls, and the framework’s node is only the driver’s view of the hardware. And the genome the machine reads is not the test’s copy at all: the installer on the live system writes it from its own template, so what the test says about the body — the driver’s door, the virtual disk, the memory, the label pinned to a constant so the working tree’sdirtysuffix cannot leak into a store hash — was in the system that booted and not in the genome that would build the next, and the next set out to build the NixOS manual and the compiler under it. So the test declares it where an owner declares hardware: a body module, copied into the installed genome, imported from the owner’s file and committed in the owner’s name, before the machine is asked for anything. The proof that the two evaluations are now one is a path: the test computes on the host, without building it, the system the genome evaluates to withhelloin it, and the machine’s second generation must be that path. On a real machine this holds by construction: the first system is built from the genome by the daemon in genesis mode, and there is no second evaluation to differ from. - A generation is registered before it is switched to. This one was found by reading,
before the machine was asked: the stem’s
activatewasswitch-to-configuration switchon the built system and nothing else, andswitch-to-configurationwrites boot entries from the system profile’s generations — a switch without the profile is a running system with no entry to boot back into, andtacid undoafter a reboot would have had nowhere to go. The stem now sets the profile (nix-env -p /nix/var/nix/profiles/system --set …) as the first step of activation and switches as the second, so every activation is a numbered generation with an entry in the boot menu. (The stem’sset-boot-defaultwas renamed at the same time to the entry it believed NixOS wrote,nixos-generation-N.conf, and was still naming one that nothing had ever written: entries are named by the hash of their content. The operation is gone; ADR-0038 records what replaced it.) - Activation is not the stem’s to run under its own confinement. With the evaluation
settled, the machine built its generation in eleven minutes and the stem ran its
switch-to-configuration— inside the stem’s own unit, whose filesystem is read-only but for what it is given, as every Tacid unit’s is. The activation script answeredinstall: cannot change permissions of '/root': Read-only file systemandmkdir /home/dom: Read-only file system, exited 4, and left a generation half switched to:/run/current-systemmoved, the package ran, the transaction concluded failed, and nothing was committed. NixOS itself runs the switch as a transient unit for the same reasons, and so does the stem now:systemd-run --wait --pipe --collect --service-type=exec --unit=tacid-activate-N, outside the confinement, named for the journal, outliving a restart of the stem should the switch restart it. The bootloader’s default and the blessing, which write the firmware’s variables and the boot partition, run the same way. - The evaluator needs a home and a feature flag. The daemon’s unit runs it under a state
directory and no
HOME; Nix’s evaluator keeps its caches underXDG_CACHE_HOME, and its flake commands are behindexperimental-featuresthat the installer’s live image had set and the installed machine had not. Both are declared once, in the core module, for every machine.
Decision
A machine builds its next generation from what it carries, and the test that installs it
proves that it does. The assembly tools are part of every system’s closure; the daemon locks
before its first build and builds from the lock after; the stem registers a generation before it
switches to it; the installation test’s last act is install hello on the installed machine,
followed by hello running, the profile holding a second generation, the genome holding its
lock and its commit, and tacid undo bringing the first generation back with the package gone.
Consequences
- The two stand-ins are gone from the loop. Every step from intent to a running generation and back — the daemon’s evaluation and build, the keeper’s verdict, the stem’s profile and switch, the health gate, the journal, the genome’s commit, the undo — has now run as the code that ships, on a machine that installed itself, with no network.
- The installation test is over an hour of software emulation, nine subtests long. It boots the netinstall in genesis mode, plans and installs from it, boots the result through a passphrase prompt, checks the ephemeral root and the person’s home, builds a second generation on the machine and takes it back, and reads the disk it did all this on. It is the test this project has instead of hardware, and it is run before a change to the installer, the stem, the daemon’s builder, or the disk layout is called done.
- The assembly tools add a few tens of megabytes to every closure. That is the second price of a machine that rebuilds offline, after the half gigabyte of sources ADR-0034 paid; a machine without them is a machine that can evaluate its declaration and not act on it.
- The list of tools is nixpkgs’ business as much as Tacid’s: a module that starts finishing the system path with another tool will surface the same way this one did, as a machine planning a bootstrap. The analysis in finding 3 is the check, and a change to the nixpkgs pin is the moment to run it.
- What has still never happened: a build that needs a package the machine does not carry.
That is
tacid update’s case, a channel with a cache behind it, and it is the next thing the installed machine is asked to do.
Alternatives rejected
- Ship a binary cache with the image and let the machine fetch its tools. A cache is a network, and the case is the network cable out. The tools are small and the closure is the honest place for what the machine needs.
- Let
nix buildown the lock by not overriding inputs, and rewriteflake.nixinstead. Aflake.nixthat names store paths is a genome that stops being portable the day it moves to a machine whose store differs. The lock is where a pin belongs; the flake stays the declaration. - Register the profile from the daemon, with
nix-envunder the agent’s uid. The system profile is root’s, and the stem is root; a numbered generation is part of activation, not something done beside it.