TacidOS
DevelopersGitHub

Decision 0033

Installation without an evaluator on the privileged path

Architecture decision record 0033: Installation without an evaluator on the privileged path

Status
Accepted
Date
2026-09-04

Context

ADR-0012 chose disko for storage and ADR-0022 described the installer as the stem, the keeper and the daemon in genesis mode. When the netinstall image was built and booted for the first time, tacid install printed a plan and changed nothing, the stem’s disk operation named a file that nothing wrote, disko was on no image, and the genome the installer composed named options that did not exist (tacid.hardware.gpu for tacid.hardware.gpu.profile, protectedPaths for filesystem.protect, tacid.owner.key for an option nobody had declared, a null channel) and imported a tacidos.lib.machine the flake did not export. None of it had ever been evaluated, because nothing evaluated a genome until a machine was installed from one, and no machine was.

Making the installation real forced the question of what disko would be doing on a live image. Its CLI evaluates Nix on the machine being installed: a copy of nixpkgs on the medium, an evaluation at install time, and a set of tool paths that must already be in the image’s store or the evaluation reaches for a cache the image may not have. That is an evaluator on the privileged path, for a layout that is one shape with two switches.

Decision

The stem lays the disk out from a typed plan, and disko is not used. DiskApply carries a [DiskPlan]: the whole disk by a stable path, the size of the EFI system partition, whether and with what passphrase the root is encrypted, how much swap, and where to leave the tree mounted. Everything else — the partition labels, the LUKS name, the Btrfs subvolumes and their mount points, the mount options — is a constant of tacid-stem-protocol. The stem turns the plan into a fixed sequence of argument vectors (sgdisk, mkfs.vfat, cryptsetup, mkfs.btrfs, btrfs, mount), and a test asserts the exact sequence without a disk. The passphrase travels as a [Secret] that prints as Secret(…), is written to cryptsetup’s standard input and appears in no argument, and is registered with the keeper’s scrubber before the first journal line.

body/disks.nix is plain NixOS, from the same constants. Layout::to_nix declares the filesystems by the partition labels the stem wrote and the subvolumes it made, with neededForBoot on what survives a reboot, boot.initrd.luks.devices under systemd’s initrd when the root is encrypted, and a swap file on its own subvolume. Nothing in it names the disk or a UUID that does not exist until the disk is made. The plan and the declaration are two methods of one Layout, so the disk the stem makes and the system that boots from it agree by construction.

Two more guarded operations finish an installation. GenomeWrite puts the first genome where each kind of root will find it — etc/tacid and persist/etc/tacid under the mount — owned by the agent’s fixed uid, which the protocol now carries as a constant beside the labels. SystemInstall does what nixos-install does once a configuration is built: the closure into the new store and the profile set to it with nix-env --store, the mark that makes switch-to-configuration willing, and the bootloader installed from inside the new root by nixos-enter — after its --, as an argument vector, so no shell is involved. Then the tree is unmounted and an encrypted root closed, so the medium can come out. Nothing is evaluated by any of the three: the system is a store path that already exists.

The keeper installs, for a person, in genesis mode only. genesis.install is a method on the keeper’s socket that answers a local user or the owner and never the agent. It composes the plan and the genome from the answers exactly as the client printed them, sizes the disk through the facts, refuses before touching anything if an answer is missing or the disk cannot be sized, and then sends the three operations to the stem with the presence a console gives, journaling each. Genesis mode is a file the live image’s configuration writes; an installed machine has no such file and no transaction can write one, so an installed machine installs nothing whoever asks.

A genome’s flake calls tacidos.lib.machine, which now exists: the module surface, a profile, and the genome’s own files, composed one way. The genome tacid install writes is a flake with that call, body/hardware.nix with the release the body was born under, body/disks.nix, an initial mind/agent.nix, the policy, the personality and owner.nix with tacid.owner.key — an option that now exists and is rendered into the keeper’s policy. The daemon keeps an installed genome at its first start rather than overwriting it with a template, and gives it its history.

The test is two machines that are one disk. The live system — the same configuration the netinstall image carries, split out so the image is only the medium — boots with an empty disk beside it and installs onto it a system composed by lib.machine from the genome that tacid install --write-genome produces for the test’s answers. That is an import from a derivation, on purpose: the alternative is a second copy of the genome, written by hand, that the test would be trusting instead. Then the live machine is shut down and the disk is booted through firmware as the installed machine, which comes up with its hostname, its genome owned by the agent and committed, its persistent copy, and its nervous system running.

What the first installation found

The test installed a machine on the sixth attempt. Each of the five before it found something that the unit tests, the wire tests and the lifecycle machine could not have, and each is fixed in the tree rather than in the test:

  • The daemon was never told where the genome is. Its unit assumed /etc/tacid, which was right on every installed machine and wrong on the first live one, where the genome lives under the state directory. It went looking in a read-only /etc, crashed, was restarted, and was “active” between attempts — so the test now asserts that no unit restarted, on both machines.

  • The keeper could not see the disk. It sizes the disk it is about to be asked to destroy, by the stable name a person gave it, and those names are udev’s symlinks in the real /dev; a private /dev has none. The keeper keeps the real /dev with a closed device policy: it may look at devices and may not open one.

  • The stem could not enter the installed system. nixos-enter puts its bind mounts in a private mount namespace and takes a uts namespace; “no namespaces” is where that stops. The stem’s unit allows those two and no others.

  • The installed system had no switch-to-configuration. The test framework leaves it out of a node unless it installs the node’s bootloader itself, to spare its builders a rebuild; a node the stem installs asks for it back.

  • The firmware crashed before the bootloader. A firmware built to require system management mode, handed to QEMU by hand without the machine type that provides it, is an invalid opcode. The target boots through the VM module’s own firmware path, which knows what it insists on.

  • The encrypted machine’s initrd waited for a root nobody had been asked to unlock. The VM module’s stand-in filesystems come with a stand-in boot.initrd.luks.devices of nothing, at a priority that beats the genome’s declaration; the unencrypted machine never noticed. The target node keeps its own filesystems now, and the encrypted root is the one that is tested.

And one thing the test found in its own environment: the impermanence module’s scripts had lost their executable bit in a local mirror, and every activation on the lifecycle machine had been printing “Permission denied” into a log nobody read, while the test passed. The mirror is fixed; the lesson — a passing test is not a clean log — is recorded here because it will be true again.

Consequences

  • Installation has no evaluator on the privileged path. The daemon builds the target from the genome before the keeper is asked, with every input pinned to the sources the image carries — ADR-0034 — and an offline image can bring the built system instead.
  • disko’s other use — declaring a machine’s disks for nixos-anywhere — is not available; a machine’s disks are declared by body/disks.nix in NixOS’s own terms. ADR-0012’s choice of disko is amended to this; its choices of GPT, UEFI, LUKS2 with TPM2, Btrfs subvolumes and impermanence stand, and the impermanence half — a root that is wiped at boot — is still a declaration rather than a mechanism, and is on the list.
  • The installer’s TPM enrolment is a declaration in body/disks.nix and not yet an operation the stem performs at install time; the machine asks for its passphrase until EncryptionKeySlot is wired to systemd-cryptenroll after the first boot.
  • The test installs the default: an encrypted root. The passphrase comes from a file on the live system, reaches cryptsetup on standard input, and the test asserts it is in no journal, no unit’s log and no command line; at boot the initrd asks for it at the console and the test types it. The unencrypted layout keeps its unit tests and is no longer the one a machine boots through.
  • The test hands the installer a built system with --system, the way an offline image would. The netinstall’s own way — the daemon’s genesis.build — is proven against a recording Nix and a recording builder rather than in the machine, for the reason ADR-0034 gives.

Alternatives rejected

  • disko on the image, with nixpkgs beside it. An evaluator as root, a cache the image cannot reach, and a layout that does not need the generality. Rejected for the privileged path; a contributor who wants disko for nixos-anywhere can render body/disks.nix into it.
  • The daemon composing the genome and asking the stem through its own keeper client. The daemon cannot approve, and an installation is the largest irreversible thing a machine does; it is a person’s request or nobody’s.
  • A shell script inside nixos-enter, as nixos-install does. nixos-enter runs an argument vector after --; there was no reason to hand it a string.