Decision 0048
A system declares what it is, and the keeper checks before it activates
Architecture decision record 0048: A system declares what it is, and the keeper checks before it activates
Context
A review of the whole tree found the hole this record closes. keeper.apply took the store path
to activate from its caller, and its caller is tacidd — the least-trusted daemon, the one with
the model in it and the parsers of other people’s data. Everything before that point was sound:
the transaction is classified, the diff is previewed, the owner’s consent is bound to the
transaction’s identity and cannot be replayed onto another. And then the daemon says “here is
what I built” and the keeper has the stem switch to it.
So a compromised daemon proposes something the classifier calls reversible — a package, a wallpaper
— gets it approved or waved through at the owner’s autonomy level, and then hands over any other
plausible /nix/store/…-nixos-system-… path. The stem checks that the path looks like a system.
Nobody checks that it is the change.
The obvious fix is for the keeper to build the change itself and compare. It cannot: the keeper holds the policy, the journal and the owner’s consent, and putting a Nix evaluator behind that door is putting the largest attack surface in the tree in the smallest, most privileged process. ADR-0033 kept the evaluator off the privileged path on purpose.
Decision
A system carries what the agent asked it to be, and the keeper reads that rather than trusting a path.
The daemon already serialises the agent’s state to mind/agent.state.json; the module that
generates mind/agent.nix from it now carries that text into the built system, at
etc/tacid-system/declared.json. Every Tacid system says what it is, in a file that can be read
off a store path without evaluating anything.
At apply, before the stem hears a word:
- The candidate must declare. A system that will not say what it is, the keeper will not run.
- For a change with items, the keeper takes the declaration of the machine as it last authorised it — seeded, when it has authorised nothing, from the system it is running — applies the transaction’s own items to it with the same code that rendered them, and requires the candidate to declare exactly that.
- For a change that names nothing — an undo — nothing can be derived, so the candidate must be a declaration this keeper has already authorised.
A development machine has no such thing: tacid serve runs in a directory, its systems come from
a stand-in builder, and the machine it runs on is not the machine being described. Such a keeper
is built with checking_nothing(), which is a call in code and not a file that could go missing.
Consequences
- The daemon can no longer choose what runs. It can propose, and it can build; what is activated is what the change the owner saw implies, or nothing.
- The keeper gained one dependency, on
tacid-tx, so that it can apply change items itself. That is the same code the daemon renders with — one definition, not a second that could drift. - An undo is checked against where this keeper has been rather than what it can derive. A keeper restarted since the change it is undoing will refuse, and say so; the owner can still boot the previous generation, which is the path that never depended on the daemon.
- The limit, said out loud: a declaration covers the surface the agent may write. A daemon
that rewrote
owner.nixand rebuilt would pass this check, because the change it made is not in the agent’s state at all. What stands in front of that is the owner’s signature on the files that are theirs, and the classifier that calls a write to them irreversible. - Machines installed before this record carry no declaration; their keeper refuses to activate anything until they are rebuilt from a genome that has one. There are none.
Alternatives rejected
- Bind the transaction to the path first seen at preview time. It moves the attack earlier rather than closing it: a compromised daemon would put the evil path in the preview, and the owner approves a diff, not a path.
- Let the keeper evaluate the genome. The evaluator is the largest attack surface in the tree; it does not go behind the door that holds consent.
- Have the stem refuse any path that is not already a registered generation. Right for an undo, useless for a new build, which is the case that matters.
What it cost the installation test
A system carries its own declaration, so two systems with different declarations are different systems. That is the point. It also means the host can no longer predict what the machine will build once the agent has written something into the genome — and one assertion in the installation test was doing exactly that.
The test asked the machine to install hello, then compared the system it built against a store
path the host computed for the same change. Before this ADR the two matched, and the match proved
something worth proving: the machine’s own evaluation is the host’s, derivation for derivation.
After it they cannot match. The machine rewrites mind/agent.state.json, mind/agent.nix reads
it into tacid.core.declared, and the host’s copy still says "packages": {}. The host would
have to run the renderer to know what the machine wrote, which is a test reimplementing the thing
it tests.
So the comparison moved rather than being weakened. “The installed machine is itself” makes it, at the one moment both sides genuinely hold the same declaration: the live image builds the first system from the genome it just wrote, and it is the very path the host computed. The later subtest keeps what it is actually for — the machine built its next generation itself, from what it carries, and can take it back.
The same bill arrived twice more, later, and is worth naming because the shape repeats. The
update test compared the generation the machine staged against a store path the host computed
for the channel’s revision — and a channel advance is built on the machine, from the machine’s own
genome, so those two cannot match either. What replaced it is stronger than what it replaced: the
machine reboots into that generation and runs hello, which exists in that revision and no other.
A hash comparison asks whether the host guessed right; running the program asks whether the
machine did what it said.
And an undo, on a booted machine, was refused outright. A change that names nothing may only
activate a declaration this keeper has authorised — that is this ADR’s rule and it is right — but
the list of those started empty at every boot, so the very first tacid undo after a reboot was
told that the system the machine had been running for ten minutes was not one the keeper had
authorised. A keeper now seeds that list with the declaration of the system it is running, read
from the same root-owned path the check below already reads. This cost a run to find as well, and
could not have cost anything less: it is a bug that exists only across a reboot.
And then the same seed turned out to be the wrong baseline for everything after the first
change. The rule above said “the running system”, and a booted machine refused its second change
with it declares something other than the machine plus the change the owner saw. The running
system is not what the next change is built on. ADR-0006 makes a kernel, a driver, a compositor
and the daemon itself boot-class — staged, not switched — so after any of them
/run/current-system stays a generation behind the genome until somebody reboots, and the keeper
was computing “the old machine plus this change” against a candidate built from a genome that
already carried the staged one. Every subsequent change was refused until the reboot. Even
switched changes only appeared to work: /run/current-system is re-read from a path the keeper
holds, and in the test that path does not move.
So the baseline is what the keeper has authorised, which is the thing the genome accumulates and therefore the thing the daemon renders from. It is pushed only where a change stands — a boot-class one, which the profile names and which boots next, and a switched one whose health gate passed. A change the gate put back is not somewhere this machine has been, and the daemon never commits it, so the two stay in step. The seed keeps its job: before this keeper has authorised anything, the machine as it last authorised it is the machine it is running.
Each of these cost a run to find, because none of them is visible anywhere but a booted machine: the closure binding is a string in a file, the two evaluations are on different sides of a socket, and nothing but an installation puts them in the same sentence.