TacidOS
Developers
Documentation

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
Date
2026-09-07

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 settings and the catalogue offer exactly what the module layer implements, and docs/cli.md says so and why the other two are absent.
  • The ai profile starts tacid-infer. It did not, for its whole life.
  • Policy::default_allowed_services loses wireguard and inference; the test that holds it against curated is 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.
  • bluetooth and printing are 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.