TacidOS
Developers
Documentation

Decision 0083

0083. A declaration covers what the agent may write, and the owner's files are not that

Architecture decision record 0083: 0083. A declaration covers what the agent may write, and the owner's files are not that

Status
accepted
Date
2026-09-08

Status: accepted. Completes ADR-0048 and makes true a sentence ADR-0007 has carried since it was written.

Context

ADR-0048 gave the keeper a way to refuse a store path without evaluating Nix: every Tacid system carries the agent’s own state, and a candidate must declare the running machine plus the change the owner approved. It closed a real hole — a compromised daemon could otherwise propose something reversible and hand over any system in the store.

Its own documentation named what it does not reach, and the sentence has been sitting in verify_declaration ever since:

The limit is worth saying out loud: a declaration covers the surface the agent may write. A daemon that rewrote owner.nix and rebuilt would pass this check, and what stands in front of that is the owner’s signature on the files that are theirs.

Four files are not the agent’s. flake.nix decides what a machine is composed of. owner.nix is the person’s own. mind/policy.nix is the agent’s authority over itself. mind/personality.md is what it is told it is. None of them appears in any declaration, because a declaration is exactly the thing the agent asked for, and these are the things it may not ask for.

The signature that was supposed to stand in front of them did not exist. OwnerKey could mint a key and print both halves; owner_key.rs said the public half goes into the genome “where the keeper reads whose signature makes a policy the owner’s”; POLICY_TEMPLATE tells every new owner that “only the owner may change this file, and the keeper checks the signature on it”. Nothing on any machine had ever verified anything with that key. Three statements of a promise and no implementation of it, which is the shape ADR-0069 is about.

Making the four root-owned in a sticky genome — done separately — stops the agent rewriting them in place. It does not stop a daemon copying the genome elsewhere, rewriting a copy, building from it, and asking for that to be activated. The declaration such a system carries is perfect: it is precisely the change that was approved. Everything underneath it is somebody else’s machine.

Decision

A system carries what its owner signed for, beside what it was built from. etc/tacid-system/constitution.json holds two things. signed is a digest of the four files and an Ed25519 signature over it, made by the owner’s key and carried out of the genome unchanged. built is those same four hashed by the module while the system was being built, with no signature at all.

The two answer different questions and are worth more together than either alone. A rewritten owner.nix changes what the build hashed and stops matching what the owner signed. A signature replayed over newer files fails the same comparison. Forging the signature needs a key that is not on the machine.

The keeper checks it at apply, beside the declaration, before the stem hears anything. The owner it checks against comes from the running system — a store path nothing on the machine can rewrite — and never from the candidate, because asking the thing being checked who may vouch for it is not a check. A candidate that carries no constitution, or one signed by somebody who is not this machine’s owner, or one whose files are not the ones the signature covers, is refused and the generation does not move.

The signature is the consent. An owner who edits their own file and runs tacid constitution sign has authorised that constitution; the keeper needs no second approval for it. What it refuses is a constitution nobody signed for.

And the check is conditional on this machine having one. A machine whose running system carries no signature has not adopted it, and there are two honest ways to be in that state: it was installed before any of this existed, or its owner brought their own public key and kept the secret half somewhere this machine has never seen, so installation could not sign for them. Refusing those would brick every machine that exists in order to protect the ones that do not yet. This is the shape ADR-0080 settled for Secure Boot: the step that cannot be taken back waits for what it depends on, rather than the feature being on for everybody and breaking the ones it was not ready for.

Consequences

  • A machine is born signed for, when installation minted its owner’s key — which is the ordinary path. Signed over the genome about to be written rather than over the genome read back, because between those two there is a disk.
  • tacid constitution and tacid constitution sign exist, and had to exist first. Turning the check on before an owner could sign would mean the first person to edit owner.nix has a machine that will not activate anything.
  • Two cases install unsigned and both are correct: an owner who kept their own secret half, and a genome whose declared owner and whose secret half are not halves of one key. The second was a gap of its own — owner_key and owner_secret are separate fields and nothing had ever required them to agree — and a machine whose two statements about its owner disagree now signs as neither rather than inventing a third.
  • The check rests on builtins.hashFile "sha256" and Sha256::digest being one thing. They are, confirmed against sha256sum and pinned in a test, along with the literal text the module writes, because an agreement across two languages breaks with nobody touching either side.
  • Five tests carry the keeper’s half and three of them fail with the check removed, which is how it is known they are tests rather than decoration.

The limit, stated rather than implied

What the build hashed is a claim the build makes about itself, and any build can lie about itself if the thing producing the claim is the thing being distrusted. The module that writes it is core (ADR-0014) — channel-signed, not the agent’s to rewrite — so the lie needs a machine already running code its channel never signed, which is a different failure, is marked in tacid version, and is not one a declaration can catch.

Where the check binds, and why that is every path. It runs in Keeper::apply, beside the declaration check, and apply is the only place in the keeper that sends the stem an Activate. Three other things move a machine between generations and none of them is a way around this. The health gate restores previous — the generation that was running a moment ago, which passed this check when it was applied, or predates it. reconcile_boot restores the generation the bootloader actually booted, which is agreement with a fact rather than a choice. And tacid undo reverts the genome’s head and builds, so it arrives here like any other change rather than jumping to an older store path. The generation the health gate would restore to is self.generation, and every write of it is inside apply itself, so an agent cannot aim a rollback by arranging for one.

What is left is a person at the boot menu choosing a generation from before the check existed. That is the owner at the console, which ADR-0010 guarantees on purpose, and a machine whose physical console is hostile has lost a different argument.

The narrower residue is real. The genome’s own flake.nix chooses which tree the module hashes, and nothing forces that to be the tree it imports. flake.nix is itself one of the four, so rewriting it changes the digest — but a rewrite that hands the module the original files while importing copies would pass. What stands in front of that is the file being root-owned on disk. Neither half closes it alone, and saying so is better than implying a completeness this does not have.

Who owns the genome directory turned out to matter twice. It was the agent’s, mode 3775, and in a sticky directory a file may be removed by its owner, by the directory’s owner, or by root. So the agent could delete the four root-owned files that are the owner’s — which is most of what making them root-owned was for. And the owner, who may not write that directory, could not create the file holding their own signature, so adoption after installation was impossible.

Both are the same fix. The genome belongs to root with the agent’s group: the agent creates and removes its own files as before and cannot touch root’s. The signature is made to exist at boot, owned by the person whose signature it is, so they overwrite a file rather than create one — and the agent cannot delete that either, because the directory is no longer its own. Empty until they sign, which the module reads as a machine nobody has signed for.

And it broke the thing it was protecting. Git refuses to operate in a worktree whose owner is not the user running it — git init succeeds, and status, add, commit and log all fail with “detected dubious ownership”. The genome is a git repository worked in by a daemon running as tacid; the moment the directory became root’s, every genome operation that daemon makes was one command. And Genome::init ends with the Genesis commit, commit begins with git add --all, and the daemon exits FAILURE when init returns an error — so on a fresh machine tacidd would not have started at all. Nothing in the tree said so, because the only things that run the daemon against a real genome are virtual-machine tests that had not been run since the change.

What git is guarding against is the directory’s owner arranging for a command to run as whoever opens the repository. Here that owner is root, which is already the only thing that could have installed the daemon’s binary, so the guard is refusing a trust that is not in question. The daemon says safe.directory for this one repository, on the command line, where it is true of exactly one path and needs no global or system configuration. The tests that read the genome’s log now read it as root, which owns it and so needs no exception at all — a test that granted itself the same exception could pass while the daemon had lost its.

The other candidate fix was to route the write the way every other privileged write in this design goes — the owner signs locally, hands the result to the keeper, the keeper asks the stem — and it is worse. It would add a “write this file into the genome” operation to the privileged path in order to place a file whose entire authority is the signature inside it, and it would make the keeper a writer of the owner’s signature. Under the fix that landed, the writers are that person and root; the agent and the keeper are neither. A file that carries its own proof does not need a privileged courier, and giving it one only widens what a compromised keeper can do.

git add --all will sweep the signature into an agent commit, so git log on the genome will attribute the file to the agent that recorded it rather than to the person who signed it. That is cosmetic here and not the ledger failure it looks like: this check reads the Ed25519 signature in the file and never a commit signature, and a commit that records an already-signed document changes nothing about who signed it.

That leaves the owner’s secret half in the one process they ran, which is where it should be, and needs no new protocol method. It is a weaker arrangement than routing the signature through the keeper — a person with a shell as dom can sign, where a keeper-mediated write could ask for consent at the console — and that is the upgrade path rather than this.

And one thing this deliberately does not do: an owner editing owner.nix from another machine and pushing to the genome cannot sign at the machine, so an unattended server’s constitution can only be changed by somebody who can reach it. The complement is the keeper verifying the owner’s signature on the genome’s git history, which is what ADR-0007 describes and which is not built.

Alternatives rejected

Carry a digest and compare it, with no signature. The smallest change, and the review that prompted this offered it as one of two acceptable routes. It works exactly as well as the declaration does — it stops an honestly built system from a different genome — and no better, because the digest is a claim the build makes and the build is what is being distrusted. Moving the hashing from the genome’s flake into the module narrows it without closing it: whatever paths the module hashes, the flake chose them. A signature is what makes a doctored digest fail rather than merely differ.

Verify the owner’s signature on the genome’s git commits. What ADR-0007 actually describes, and the honest complement for unattended edits. It puts git parsing and git’s own signature machinery inside the one process that must not be talked into anything, to answer a question a forty-line digest answers. It belongs behind its own design rather than smuggled in with this.

Enforce unconditionally. Simpler to state and simpler to trust, and it would refuse every machine in existence on the day it shipped, including every machine whose owner did the right thing with their own key. A security check that has to be introduced by breaking every deployment is one that gets reverted.

Keep the owner’s public key in the keeper’s own state rather than reading the running system. Tempting, because the keeper’s state is root’s. But the running system is a store path — immutable, and already what ADR-0048 seeds its authorised declarations from. Two sources for one fact is how they come to disagree, and the second one would need its own migration, its own backup story, and its own answer for what happens when it is missing.