Decision 0078
0078. A name a machine offers and nothing implements is a machine lying about itself
Architecture decision record 0078: 0078. A name a machine offers and nothing implements is a machine lying about itself
Status: accepted. Repairs ADR-0008’s surface, and applies ADR-0076’s rule to the module layer.
Context
tacid.services is the agent’s allow-list: a name is there only once somebody has looked at it.
The list is one attribute set, curated, in nix/modules/services.nix, and it feeds three
readers — the options a genome writes into, the catalogue a system carries in
/etc/tacid-system/services.json, and a person reading the file. That was written down as the
arrangement in which they “cannot drift”.
There was a fourth reader, and it was a hand-written block of mkIf lines below the list. It had
an arm for nine of the eleven names. wireguard and inference had none.
So tacid do enable wireguard passed the catalogue’s check — the reference says a service the
module layer does not implement is refused by name — rendered tacid.services.wireguard.enable = true into the genome, evaluated cleanly, built, activated, and changed nothing whatsoever. The
machine reported a change it had not made. That is the worst thing this project can do; every
other guarantee is downstream of a machine’s account of itself being true.
inference was worse in a quieter way. The local inference supervisor is gated on
tacid.core.infer.profile, which is core and the owner’s — the one decision the agent must not be
able to make about itself is which mind it is. The service entry was a second name for that
decision, wired to nothing. And nix/profiles/ai.nix — the profile whose entire purpose is a
machine that thinks locally — turned it on and started no inference supervisor at all. Nothing in
this tree composes that profile, so nothing ever evaluated it.
Two more lists say something about the same nine names. Policy::default_allowed_services in
tacid-policy says which the agent may enable, and a test already held it against curated.
Classifier::default_exposing_services says which make the machine reachable from somewhere else
— and it had no wireguard, while Classifier::add_exposing_service, the hook for teaching it,
had no caller anywhere. A VPN would have classified as reversible and, at level 2, been applied
with a notification rather than a question.
Decision
A curated service carries its own implementation. Each entry gains config, a function from
its settings to the NixOS configuration enabling it means, and the module’s config is
mkMerge over that same attribute set. A name with nothing behind it no longer evaluates. This is
the shape the fix has to have: the failure was not inattention, it was an arrangement in which
inattention was expressible.
A curated service says whether it makes the machine reachable. reachable sits beside
description and persist, travels in the catalogue, and the keeper reads it at startup and
teaches the classifier. The Rust list stays as a floor — it has to be right on a machine whose
catalogue cannot be read — and the catalogue only ever widens it, so the worst these two can do to
each other is make a machine ask about something it need not have. crates/tacid-tx/tests/surface.rs
fails the build if a service the module layer calls reachable is one the shipped list does not
know.
Nine services, and the two that were fiction are gone. A VPN is not
tacid.services.wireguard.enable = true: without peers, keys and addresses that is not a VPN, and
declaring one is owner.nix’s until there is a curated shape worth offering. The local mind is
tacid.core.infer.profile, and nix/profiles/ai.nix sets it now, which is the first time that
profile has done what it says.
Every profile this flake offers is evaluated by a check. checks.<system>.profiles forces
each profile’s toplevel.drvPath against the least body a machine can have. Not built —
evaluation is what catches an option that is not there any more, and it costs seconds. Only
workstation and headless were composed anywhere in this tree; ai, embedded and minimal
were read by nobody.
Consequences
- Nine curated services, each of which does something.
tacid settingsand the catalogue offer exactly what the module layer implements, anddocs/cli.mdsays so and why the other two are absent. - The
aiprofile startstacid-infer. It did not, for its whole life. Policy::default_allowed_servicesloseswireguardandinference; the test that holds it againstcuratedis what says so.- Adding a service now means one entry: sentence, persistent state, settings, whether it is reachable, and what it does. Forgetting any of the five fails, in Nix or in a test, rather than on somebody’s machine.
bluetoothandprintingare marked not reachable, deliberately. Bluetooth is not discoverable until a person makes it so, and CUPS as declared here listens on the loopback. A guard that fires on those is a guard people learn to click through, and the cost of that is every guard.
Alternatives rejected
A test that every name has an arm. Cheaper, and it is vigilance with extra steps: the arm can be empty, the test can be renamed around, and the next reader still finds two lists. Making the wrong state unrepresentable is not more work here — it is less.
Implement wireguard minimally, so the name survives. enable = true with no peers produces
a machine with a VPN service and no VPN, which is the same defect wearing a different hat.
Let tacid.services.inference.enable set tacid.core.infer.profile. It reads well and it is
an escalation: the agent may write tacid.services.* and may not write tacid.core.*, and a
service switch that reached through would be the agent choosing which mind it is by another name.
Have the classifier read only the catalogue. One list instead of two, which sounds like this project’s own rule — and the catalogue is a file, and a file can be missing. The classifier has to be right when it is. Two lists where one can only add to the other is a different shape from two lists that must agree, and it is the safe one.