TacidOS
Developers
Documentation

Decision 0077

0077. An operation that names a unit is a shell with a longer spelling

Architecture decision record 0077: 0077. An operation that names a unit is a shell with a longer spelling

Status
accepted
Date
2026-09-07

Status: accepted. Extends ADR-0004’s rule that the stem has no run(string), and ADR-0013’s account of where an update is decided.

Context

A machine checks its channel on a timer — hourly on edge, daily on stable. Until now that was the only way the check ever ran, and docs/cli.md said so in the not-built list: the timer follows a channel; there is no verb for it. A person who wanted to know whether an update was waiting had to either wait for the hour or systemctl start tacid-update.service as root, which is asking them to reach past every mechanism this project exists to build.

So: tacid update. The question is what it is allowed to be.

The check is not a small thing. It asks a cache what a channel points at, refuses to believe the answer unless the maintainer signed it, decides whether now is a quiet moment, and proposes the advance. Every one of those steps is a place where a wrong answer becomes what this machine becomes at the next reboot.

Decision

The verification stays where it is: in tacid-update, a core binary. The verb starts that binary; it does not reimplement any part of it. A daemon that fetched a manifest, believed it, and proposed with system provenance would be the agent deciding what the machine becomes — and the agent may rewrite the daemon (ADR-0014). The one process that must not be the agent’s is the one that says which revision is real.

The path is tacid → keeper → stem → the unit. Not tacid → daemon: the daemon is the least-trusted process and has no business starting units. Not tacid → systemd: starting a unit is root’s, and root on this machine is the stem.

The stem’s operation takes no argument, and that is the whole of why it is safe. Operation::UpdateCheck is a bare tag; the unit it starts is UPDATE_UNIT, a constant of tacid-stem-protocol. An operation that took a unit name would be “root starts anything”, spelt as a type: every service on the machine one string away, and so is anything a caller can first write into a unit file. The stem’s vocabulary has no run(string) (ADR-0004) and this is the same rule applied one level up — a name is an argument, and an argument is a shell.

Anyone at the socket may ask. The check reads a signed manifest and at most proposes; the proposal is a transaction like any other, classified, gated and journalled. A gate here would refuse the agent — the principal most likely to notice an update is waiting — and would protect nothing. What asking can do is move an update the policy already permits to a moment of the caller’s choosing, which is why the keeper writes down who asked.

An airgapped machine is told, not started. The check would reach for a cache it cannot reach and spend its ten-minute timeout finding out. A unit that fails while the machine is doing exactly what its owner asked for is a machine that cries wolf, so the keeper answers instead — started is false, and that is an answer rather than a failure.

It does not wait. The stem starts the unit with --no-block, because verifying a manifest and building from a cache is minutes and the stem answers one request at a time. tacid update says the check is running and where the answer will appear, which is tacid pending.

Consequences

  • tacid update is built, and docs/cli.md — a document a test executes — no longer lists it as absent. tacid channel stays absent: moving a machine between channels is a change to the genome, and changes to the genome go through the transaction path rather than a verb.
  • The update leg exercises the verb end to end for the second revision, and the unit directly for the first, so both halves are proven: the timer’s, and the person’s.
  • The stem’s vocabulary grows by one bare tag. operation_names_are_unique grew a wildcard-free match at the same time, because it was checking sixteen of twenty operations and calling that a statement about all of them.
  • tacid version reports the channel the genome declares. It did not: the daemon read TACID_CHANNEL with a fallback of dev, nothing set it, and every installed machine reported dev whatever its owner had chosen. A seam test in crates/tacid-keeper/tests/paths.rs now holds the daemon’s unit to that variable, beside the three paths that were wrong before it.

Alternatives rejected

The daemon performs the check. It already speaks to the cache through the proxy and already proposes transactions, so this is the shortest path — and it puts the decision about what this machine becomes inside the process the agent may rewrite. Rejected on ADR-0014’s own terms.

Operation::StartUnit(name), with an allowlist in the stem. A list is a second place to be wrong, and it invites a third entry, and a fourth. A constant per operation costs one variant and cannot be widened by a caller at all.

Make the verb wait and report what the check found. It would read better and it would hold the keeper’s answering thread open across a network fetch and a Nix build. The keeper is what everything else on the machine asks for permission; a verb that can block it for ten minutes is a verb that can stop the machine. What the check found belongs in the queue and the journal, where it is anyway.

Require consent to ask. Considered, because the check spends egress. The budget is kept by the proxy, where the bytes are (ADR-0071’s sibling), and the proposal needs consent regardless — so the gate would cost the agent a useful reflex and buy nothing that is not already bought.