Decision 0032
What the first boot found
Architecture decision record 0032: What the first boot found
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
unsafefor the one descriptor.forbidis 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.