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. 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 updateis built, anddocs/cli.md— a document a test executes — no longer lists it as absent.tacid channelstays 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_uniquegrew 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 versionreports the channel the genome declares. It did not: the daemon readTACID_CHANNELwith a fallback ofdev, nothing set it, and every installed machine reporteddevwhatever its owner had chosen. A seam test incrates/tacid-keeper/tests/paths.rsnow 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.