TacidOS
DevelopersGitHub

Decision 0032

What the first boot found

Architecture decision record 0032: What the first boot found

Status
Accepted
Date
2026-09-04

Context

ADR-0031 recorded what building the crates changed. This records what booting them changed. The unit layer — nix/modules/core.nix, the users, the hardening, the sockets — was written from the architecture and evaluated by Nix, and the binaries were written from the same architecture and tested in one process. Neither had met the other. The first time a machine booted them together, under software emulation in a container with no KVM, the keeper’s unit failed before the first subtest, and reading why turned up seven things that were true of the code and the units separately and false of the two together.

None of them reverses a decision. Each is a place where the process split the architecture promises had been drawn on paper and not yet in the code.

Decision

1. The keeper is a process the daemon talks to, not a library the daemon links. tacidd opened the journal, read the stem’s token and built its own Keeper; tacid-keeper ran beside it with a keeper of its own and an empty pending list. An approval brought to the keeper’s socket never reached the keeper the daemon was asking. Now the daemon holds an Authority — the keeper across its socket on a machine, the keeper in-process in a test or a development server — and everything it may do is a method on that trait. Approving is not among them. keeper.submit, keeper.apply, keeper.classify, keeper.pending, keeper.state, policy.get and journal.recent are the daemon’s side of the wire, and the first two answer only the agent’s uid. Refusals cross as data (not_approved, not_pending, refused), so the daemon receives the same error it would have in-process rather than a sentence to recognise.

2. Consent has a wire path. No binary offered one: the interface said “approve it with the keeper” and there was nothing to type. tacid approve <id>, tacid deny <id> and tacid pending open the keeper’s socket directly; a yes typed in the interface while a change waits does the same and never reaches the mind. Approving is two calls on purpose — the yes to the keeper, then tx.apply to the daemon — because the daemon cannot have invented what the keeper just wrote down, and the keeper checks its own record before the stem hears anything.

3. Every socket is a socket unit. The daemons made their own sockets in /run/tacid, and every unit claimed RuntimeDirectory=tacid: systemd chowns that directory to each unit’s user in turn and removes it when any of them stops. The stem’s 0600 keeper-owned socket would have been the daemon’s by the time the daemon started. Now /run/tacid is root’s, created by tmpfiles and written by nobody; each socket is created by its unit with the owner and mode the unit declares; and each service takes the inherited descriptor, or binds the path itself where there is no manager. The adoption is the one operation safe Rust cannot express, and it lives in the listenfd crate behind a twenty-line crate of Tacid’s own, so that every Tacid crate still forbids unsafe.

4. The token is minted before the socket. The stem refuses to run without this boot’s token, and nothing wrote one. The stem’s socket unit now runs tacid-stem token before it binds, so the keeper never finds one without the other; the file is root’s, readable by the keeper’s group and by nothing else, and the daemon’s inability to read it is a subtest.

5. State directories are per unit and never a parent. StateDirectory=tacid on the daemon would have chowned /var/lib/tacid — the keeper’s journal included — to the agent, recursively, at the daemon’s first start; ReadWritePaths naming directories nothing had created would have failed the namespace before the first instruction. Each unit now owns exactly the directories it writes, and the one path outside its state the daemon writes, the genome, exists before it does.

6. The policy the keeper enforces is a store path. It was rendered into /etc/tacid/, which is the genome, which the daemon commits with git add --all. It is now a store path passed in TACID_POLICY: a function of the generation, changing with it atomically, and out of the daemon’s reach by construction. The keeper also parses it now, with a sovereign window that starts when the keeper reads it; before this it checked the file was JSON and used the baseline.

7. A missing credentials file is no credentials, not no proxy. The egress proxy stopped when its file was absent, which is every fresh install and every machine without a cloud key. The update cache and a local mind need the route out just as much.

Two smaller ones: the stem’s unit had PrivateNetwork=yes, which would have had nft programming a firewall nobody uses; and tacid-keeper health opened the journal beside the keeper that owns it, which is how a hash chain breaks. Both are gone.

And one that was not wrong: the seccomp filter. The binaries call prlimit64 at startup — glibc’s getrlimit, for the main thread’s stack guard — and ~@resources was suspected of blocking it. It does not: in the systemd this tree builds against, prlimit64 is in @default. Every syscall each daemon makes through startup and a transaction was traced and checked against the expanded allow-list before this was believed.

Consequences

The VM test now proves what the unit tests cannot: the four sockets exist before anything speaks and each has its declared owner and mode; each process is its own user; the daemon cannot open the stem’s door or read its token or append to the journal; a reversible request is carried out across three processes; an irreversible one waits, the agent’s own uid is refused when it tries to approve, a person’s yes is carried out, and the journal verifies with all of it inside. In the container it runs under software emulation; in CI it runs under KVM, in nix flake check.

What is still trusted about the daemon is what it says about provenance. The keeper enforces the autonomy level, the capability decisions and the classification from the transaction’s body, and those hold whatever the daemon claims; the taint cap relies on the daemon labelling its own context honestly. Closing that needs the session executor, which establishes principals on its own socket, and it is on the list.

Alternatives rejected

  • Keep the keeper in-process and make the unit layer say so. It would have made the units honest and the architecture false. The manifesto’s promise is the process boundary.
  • Have the keeper notify the daemon of approvals. A push from the keeper would let the daemon act without a person’s second call, which is convenient and removes the property that every activation is traceable to a request a client made. The two-call shape stays; a client that wants one command has one.
  • Allow unsafe for the one descriptor. forbid is a promise about Tacid’s crates, not about the world; the exception is kept outside them, where a reader can find it in one place.