TacidOS
Developers
Documentation

Decision 0082

0082. A flake with no lock is not a value

Architecture decision record 0082: 0082. A flake with no lock is not a value

Status
accepted
Date
2026-09-08

Status: accepted. Makes true a claim ADR-0001 has made since the first commit.

Context

This repository has never had a flake.lock. Not removed, not ignored, not stale — absent, from the founding commit to today.

So there is no revision of anything in this tree. nixpkgs resolves to whatever nixos-unstable points at in the second the build starts, and impermanence, lanzaboote, crane and rust-overlay resolve to whatever their branches point at in that same second. Two builds of the same commit, an hour apart, are builds of different operating systems.

ADR-0001 says the opposite in as many words: “nixpkgs is consumed pinned through Tacid channels, never raw nixos-unstable.” MANIFESTO.md’s second principle is that the machine is a value — declared, diffable, rebuildable, with no hidden state. A branch on somebody else’s server, read at build time, is hidden state, and it is the largest single input this project has.

Two consequences, both of which had already happened by the time anybody looked. A third was guessed at and is wrong, which is worth recording beside them: the hour CI’s Nix job takes is not an unpinned input. The nixos-unstable branch is advanced by Hydra only once the channel’s tests pass and the cache is populated, so it names a built revision by construction, and pinning changes nothing about that. The hour is this project’s own Rust, compiled from source on every push and compiled three times over — the rust job, the injection job and the Nix sandbox each build it — with nothing cached between runs. What fixes that is a binary cache for this tree’s own derivations, and it is not this decision.

A red run could mean nothing at all. An input moving under a tree is a failure with no change on this side, and a project whose CI goes red without a commit teaches its readers to stop believing CI. That is a worse loss than the hour.

And the inputs this tree declares had never been exercised. Every virtual-machine test run while this was written passed --override-input for all five, against copies on disk, because the sandbox they run in cannot reach the fetcher flake.nix names. So the one arrangement that was proven is the one no user has, and the arrangement every user has was proven by nothing. That is the question ADR-0069 asks, answered badly: the whole Nix layer rested on a file that did not exist, and nothing checked.

The second half of this is the fetcher, which is why the lock could not simply be written.

github:NixOS/nixpkgs/nixos-unstable does not name a revision, so Nix resolves the branch through api.github.com — a second service, rate-limited per address, that is not the one the repository is served from. flake.nix already says this, in a comment written for lanzaboote:

git+https rather than github: throughout, for this input and for the two it needs: the github: fetcher goes through api.github.com, which is rate-limited per address and is a second service to be reachable. A git fetch over https is the protocol the repository actually speaks, works against any host, and is what a machine rebuilding itself behind a proxy can do.

That paragraph is correct and it governed one input out of five. A rule kept at one door is not a rule (ADR-0076), and the three doors it was not kept at are the ones that matter — nixpkgs is the input a machine cannot rebuild itself without.

It is not a hypothetical. The environment this was written in can reach github.com over git and cannot reach api.github.com or the archive endpoint at all, so the flake as declared could not be locked or evaluated there. Any machine behind a filtering proxy, on a mirror, or on a GitHub Enterprise host is in the same position, and “rebuilds itself from its own declaration, anywhere” is the product rather than a convenience.

Decision

The lock is committed, and it pins nixpkgs at 3ed67ec0a4d3c7ab4ae1f04f8ee8df07bfa506a2. That is the revision every test in this tree has actually been run against, which is the only revision a lock has any business naming. Pinning what was tested is what a lock is for.

Every input is fetched over git, by the protocol its repository speaks. No input resolves through a vendor’s API. The comment quoted above now describes the file it is in.

The identity of the two fetchers was checked rather than assumed. The concern with moving nixpkgs off github: is the binary cache: a source tree that differs by one byte is a different derivation and a runner downloads nothing. A git checkout and a GitHub archive of one revision differ only through export-ignore in .gitattributes and through submodules. nixpkgs has neither — checked, on the revision being pinned — so the trees are identical, the derivations are identical, and cache.nixos.org is hit exactly as before.

Consequences

  • Two builds of one commit are two builds of one operating system. This is the first day that is true.
  • A red run means a change in this repository again.
  • The pinned revision is a published channel release — releases.nixos.org/nixos/unstable/nixos-26.11pre1066106.3ed67ec0a4d3 answers — so cache.nixos.org is complete for it. That is a property of the revision rather than of pinning, and it is the reason a lock may name this one without making anybody build more than they did.
  • nix flake update still works and still advances every input; what changes is that advancing them is now a commit somebody makes, rather than a thing that happens between two builds.
  • The first fetch of nixpkgs is a shallow git fetch rather than a CDN tarball, which is slower on a cold machine and is paid once. github: is the convention, and this tree now departs from it deliberately.
  • Anyone building this flake behind a proxy that permits git and refuses api.github.com can now do so: a contributor with a fresh clone, a runner, a nix build of the repository. An installed Tacid machine was never in that position and this changes nothing for it — it rebuilds from the store paths it carries, with --override-input for every input (ADR-0034, ADR-0037), so it resolves no URL at all. Which is the same asymmetry as the rest of this record, one layer further out: the machine was insulated, and the project that builds the machine was not.
  • The virtual-machine tests can be run against the flake as declared. They were run through --override-input against copies on disk because the flake as declared could not be evaluated where they run; with every input fetched over git and pinned, the override is no longer the only way, and the arrangement under test becomes the arrangement users have. The overrides are still the fast path for an edit-and-run loop, which is a different thing from being the only path.

Alternatives rejected

Keep github: and generate the lock somewhere with network access. This is the smallest change and it leaves the reason the lock was missing exactly where it was. The fetcher would still need a second service, the sandbox that runs the virtual-machine tests would still be unable to evaluate the flake as declared, and those tests would go on proving an arrangement no user has.

Keep github: and write the hashes by hand. A narHash cannot be checked without fetching what it hashes, so this is a number entered on faith into the one file whose whole purpose is that nothing is taken on faith. It is also self-defeating: wrong, it fails every build for everyone; and the environment that would have to write it is the environment that cannot verify it.

Pin nixos-unstable as it stands today rather than the tested revision. A newer revision is not a better one; it is one nothing here has been run against, chosen for being newer. That is the habit the lock exists to break, performed while writing the lock.

Leave it unlocked and let the channel do the pinning. ADR-0013’s channels do pin what a machine follows, so a released Tacid was never going to hand a user nixos-unstable HEAD. But the channel is downstream of this repository: it pins what CI built, and what CI built was whatever the branch said that morning. An unpinned input does not become pinned by being copied.