TacidOS
Developers
Documentation

The build plan

TacidOS — Build Plan

The comprehensive build plan: architecture, surfaces, security, release engineering, phases.

Companions: MANIFESTO.md (why), docs/architecture.md (stable reference), docs/decisions/ (every settled question), SECURITY.md (invariants and threats).

This plan is written to be executed phase by phase, largely by coding agents working under the maintainer’s review. Every phase has an exit criterion that is a demonstration, not a feeling. Every work item has an acceptance test. When this plan and an ADR disagree, the ADR wins; when either disagrees with a hard invariant in SECURITY.md, the invariant wins.


1. Summary and vocabulary

TacidOS is an open-source, agent-first Linux distribution in which the agent is the operating system, the computer is its body, and Tacid is the nervous system. A machine is a declaration (the genome) built by Nix into an atomic generation. Every change is a typed transaction on one deterministic API. A tiny privileged core executes, an isolated keeper authorizes and audits, and the agent daemon plans and acts through them without ever holding a secret or a root shell. The machine works fully offline, updates itself continuously from a signed channel, rolls back what fails, and can improve its own source through a human-signed proposals loop.

TermMeaning
BodyThe machine: hardware, firmware, kernel.
Nervous systemTacid: tacid-stem, tacid-keeper, tacid-egress, tacidd, tacid-infer, tacid-session, tacid-shell, tacid.
MindWhatever model or models the owner chose, local or remote. Pluggable, never assumed.
OwnerThe human the machine serves, identified by the owner key. Writes the constitution (policy).
PrincipalAny authenticated actor: owner, local user, the agent, a remote agent, voice (untrusted).
Genome/etc/tacid, a git repository: the complete declaration of this machine.
GenerationA built, activatable system closure (a NixOS generation) plus the matching state snapshots.
TransactionA typed request for change. Kind action (ephemeral) or change (persistent, renders Nix).
Stemtacid-stem: root, typed operations only, accepts only the keeper.
Keepertacid-keeper: policy, reversibility, taint, approvals, secrets, audit, health, snapshots.
Egresstacid-egress: the only path to the network for agent processes.
ChannelA signed stream of tested (tacidos, nixpkgs) revisions: stable, edge, dev.
Immune systemHealth gate + automatic rollback + boot counting, owned by the keeper and the bootloader.
ProposalAn agent-drafted change to Tacid’s own source, with CI and evaluation results, awaiting a human signature.
FactsThe structured live model of the machine (system.*), derived from kernel and systemd sources, never from parsed shell output.
Provenance / taintOrigin labels on every context item; taint propagates and caps autonomy.
SessionA conversation or job bound to a principal, with its own tool visibility and budgets.

2. Goals, non-goals, people

2.1 Goals for 1.0

  1. Agency. The agent can inspect, change, diagnose and repair the machine, including its own source, through one typed, previewable, reversible API.
  2. Sovereignty. Fully functional offline. No accounts, no telemetry, no relay. The owner chooses the mind and holds the keys.
  3. Self-maintenance. Continuous signed updates with automatic rollback. Install once, never reinstall.
  4. Beauty. One language, one protocol, one design system, a complete terminal interface and a quiet graphical shell.
  5. Builders’ workstation. Developer, embedded and local-AI workflows that work out of the box on documented hardware, including NVIDIA.

2.2 Non-goals for 1.0

A new kernel, libc, init, package manager, compositor, terminal or browser. Universal hardware support. Phones and tablets. A hosted inference gateway or any Tacid account. Gaming positioning. Hiding Linux: a shell, SSH, git, systemd tooling and the Nix tools are always there.

2.3 People

  • The builder. Developers, embedded engineers, security researchers and local-AI people who live in a terminal and a tiling compositor, want the machine to do things on request, and will not accept an agent that can quietly break it.
  • The operator of a headless node. A server, a lab box, a board on a bench, a van: the same nervous system with no display, reached over SSH or by another Tacid.
  • Not yet. Beginners, kiosks, fleets with directory services, daily-driver phones.

3. Architecture

This section is the design, written in the present tense because that is the tense a design is written in. It is not a claim about what is built: §11 carries the status, item by item, and where a paragraph here describes something that does not exist yet §11 says so. Two of them are worth naming at the door because they read most like descriptions of a working machine — the firmware coming to verify a signed boot chain (§3.18), which is built up to the last step and proven up to the firmware, and declared WireGuard peers (§3.19), which are not built at all.

3.1 Layers

┌────────────────────────────────────────────────────────────────────────┐
│  Surfaces      tacid (CLI/TUI) · tacid-shell (layer shell) · voice      │
├────────────────────────────────────────────────────────────────────────┤
│  Nervous       tacidd · tacid-session · tacid-infer · tacid-egress      │
│  system        tacid-keeper (authority) · tacid-stem (privilege)        │
├────────────────────────────────────────────────────────────────────────┤
│  Declaration   /etc/tacid genome · tacid.* modules · tacidos flake      │
├────────────────────────────────────────────────────────────────────────┤
│  Engine        Nix (Lix) · nixpkgs (pinned via channels) · systemd      │
├────────────────────────────────────────────────────────────────────────┤
│  Body          Linux kernel · firmware · hardware                       │
└────────────────────────────────────────────────────────────────────────┘

3.2 Process model and trust zones

ProcessRuns asNetworkResponsibility
tacid-stemrootnoneSocket-activated; accepts only the keeper’s uid plus a per-boot token. Typed operations: activate(generation, class), restore(generation), reboot, shutdown, disk.apply(plan) — a typed plan, laid out by the stem itself as fixed argument vectors, not a disko file (ADR-0033) — genome.write, system.install, udev.reload, principal.ensure, bless_boot. No run(string), and — since ADR-0084 — nothing that reads its instructions from a file either. Refuses LUKS-slot, TPM-enrollment, Secure-Boot-key and bootloader changes without owner-present confirmation. Own minimal protocol crate. Fuzzed.
tacid-keepertacid-keeperIPAddressDeny=anyPolicy engine; reversibility classifier; taint tracking; approvals (clients connect to the keeper directly, bound to a transaction id); secrets broker (systemd-creds, sops/age, Secret Service façade for apps); audit sink (root-owned append-only file, hash-chained, sealed to a TPM NV counter that only goes up, so the chain catches an edit and the counter catches a truncation; and an off-machine witness the owner keeps for the root that moves both); health gate and automatic rollback; Btrfs snapshots of /persist and /var/lib/tacid before data-touching transactions and at every activation.
taciddtacidvia egress onlySessions and principals; the agent loop; facts; memory; transaction rendering; nix eval/nix build in a memory-capped cgroup; model clients. Least-trusted daemon: no secrets, no root, no direct network.
tacid-infertacid-infer, video/rendernoneSupervises llama-server as a sandboxed subprocess (read-only model files, seccomp, no network, GGUF-embedded chat templates ignored). Exposes /run/tacid/infer.sock (OpenAI-compatible) to any local application. Evicts models on memory pressure; yields VRAM to owner workloads. whisper-server is reached through a door the supervisor holds: whisper.cpp v1.9.2 calls bind_to_port and has no AF_UNIX path, so it binds a port on loopback inside this unit’s own network namespace and the supervisor copies bytes between that and the socket (ADR-0086).
tacid-egresstacid-egressloopback in, allowlist outThe route to it is a unix socket in /run/tacid, and it is the only process in this system that can open an IP socket at all: every Tacid unit including the daemon now carries RestrictAddressFamilies = [ "AF_UNIX" ], which is a seccomp filter on socket(2) and stronger than the nftables pinning four documents used to describe (ADR-0084, §3.9). Enforces the domain allowlist from the keeper, injects cloud credentials (the agent never sees a key), records the per-call egress ledger with data classes, drives the NET badge, implements one-command airgap.
tacid-sessionthe personnoneOne executor per person, a system unit started as them the first time the daemon asks, on a socket the system made (ADR-0035). Reads their files as them, read-only; opens what is not text with a parser in a sandbox; answers the daemon and the person, by socket credentials, and nobody else; everything it returns is untrusted. Voice capture and playback, desktop D-Bus and portals: a second, session-bound component, later.
tacid-shellthe user—Wayland shell, a layer-shell client that knows nothing about the compositor under it: it has drawn unmodified under Hyprland, Niri and headless sway (ADR-0053). Pure renderer of protocol state.
tacidthe user—CLI; with no arguments the TUI. Pure renderer of protocol state.

Request flow: tacidd requests, tacid-keeper authorizes, tacid-stem executes. Crash policy: any process dies alone. If tacidd is down, the : verbs, the CLI’s deterministic subcommands and a tty still work. If the mind is unreachable, everything but intelligence works.

3.3 Protocol

  • JSON-RPC 2.0 over Unix domain sockets, newline-delimited, with file descriptors passed by SCM_RIGHTS for audio, pseudo-terminals and large blobs. Notifications carry streams (tokens, tool events, progress).
  • One crate, tacid-protocol, defines every message with serde; JSON Schema is published from it and versioned. The stem has a separate, minimal crate.
  • Authentication: SO_PEERCRED plus a per-boot token; principals are established at connect time. No polkit in the Tacid path (it needs a session agent that does not exist on a serial console). A machine with a screen enables it for the other case ADR-0005 names — a desktop session asking logind to suspend or shut down — which needs no agent, because logind answers yes for an active seat session and asks nobody.
  • MCP is JSON-RPC 2.0 too: one codec covers clients, tools and helpers. Tacid exposes an MCP server for other agents; third-party MCP servers are consumed as untrusted integrations.
  • Transport-agnostic by construction: the same messages travel over SSH or WireGuard later so a brain can drive a remote body.
  • The protocol carries UI state (plan, live tool state, diffs, pending approvals, badges). Clients render; they do not decide. That is how parity between the TUI and the shell is enforced.
  • D-Bus via zbus only for desktop interop (notifications, portals, media keys).

3.4 The genome and on-disk layout

/etc/tacid/                      git repository; the declaration of this machine
  flake.nix  flake.lock          inputs.tacidos pinned; composes body + mind + owner
  body/hardware.nix              probed at install; regenerated by `tacid body probe`
  body/disks.nix                 the disk as NixOS declares it, from the labels the stem wrote; the root ephemeral
  body/gpu.nix                   graphics profile (nvidia-cuda | nvidia-graphics | nouveau | amd | intel)
  mind/agent.nix                 tacid.* options written by the agent            (agent-signed commits)
  mind/agent.state.json          what the agent asked for; agent.nix is rendered from it
  mind/policy.nix                autonomy, capabilities, allowlists, budgets     (owner-signed commits only)
  mind/personality.md            the system prompt; versioned; never hidden      (owner-signed)
  owner.nix                      free Nix; may import Home Manager               (owner-signed)
  pkgs/                          agent-authored derivations; sandboxed builds; untrusted-tagged
  secrets/                       sops-nix, age-encrypted; keys never in the store in the clear

/persist/                        the only state that survives a reboot (impermanence)
  home/  etc/tacid/  var/lib/tacid/  etc/machine-id  etc/ssh/  var/lib/<declared service state>/

/var/lib/tacid/                  state, never in the genome
  models/                        content-addressed model blobs + registry cache
  memory/machine.sqlite          SQLite + FTS5 + sqlite-vec, the machine's, with layers
  journal/                       keeper-owned; audit.log is root-owned append-only
  cache/                         evaluation caches, indices
  snapshots/                     Btrfs snapshot metadata

/run/tacid/                      sockets: stem.sock (0600 keeper) · keeper.sock · tacidd.sock · infer.sock · egress.sock

The owner is a key. Owner-only paths are those whose last commit is signed by the owner key; the agent key signs the agent’s commits; the keeper refuses to load a policy whose head commit is not owner-signed. git log is the provenance ledger of the genome. mind/ is portable (“reincarnation” onto new hardware); body/ is regenerated. Untracked files are invisible to a flake, so tacidd stages before every evaluation. Memory and journal never live in the genome: flakes copy the tree into the store on every evaluation.

3.5 Transaction lifecycle

Two kinds share one envelope, one policy path, one journal:

  • action (ephemeral): run tests, open a serial monitor, flash a board, connect to a Wi-Fi network, take a screenshot, fetch a page. Executed by tools, sandboxed by default.
  • change (persistent): anything that should survive a reboot. Rendered into the genome.

Lifecycle of a change:

 1 intent            natural language, a `:` verb, `tacid do`, a script, or another agent
 2 typed change      one or more ChangeItems (package.ensure, service.enable, udev.profile, option.set, …)
 3 keeper            classify reversibility over the rendered diff; compute taint; check budgets
 4 policy verdict    allow | ask | deny, with the reason
 5 render            tacid.* option edits in mind/agent.nix (or pkgs/ for agent derivations)
 6 stage + eval      git add; nix eval; schema errors are returned to the model verbatim, they are actionable
 7 build             nix build in a memory-capped cgroup; cache hits expected; closure delta recorded
 8 preview           human summary · option diff · full evaluation and activation log
 9 approval          if required: client → keeper, bound to the transaction id; never through tacidd
10 activation class  switch | session-restart | boot   (kernel, driver, compositor, tacidd itself are boot-class)
11 stem activates    switch-to-configuration-ng; boot-class changes are staged for the next quiet reboot
12 health gate       units reached target · agent self-test (stub eval + tool-use smoke) · declared checks
13 rollback          on failure the keeper re-activates the previous generation and journals why
14 journal + commit  append-only journal entry; agent-signed commit to the genome with the transaction id

Rollback claims are three-way and honest: system (generation), data (Btrfs snapshot taken before any data-touching transaction), external (never: firmware, remote services). tacid undo is a generation switch and a genome revert as one operation.

All three are now built or honestly refused. The keeper takes a read-only Btrfs snapshot of every declared root before it hands a data-touching change to the stem, names it for the transaction, writes down which it took, and refuses the change outright if the snapshot cannot be taken — because proceeding would mean telling an owner their file can be put back when it cannot (ADR-0040). The classifier counts on it, so a write inside a declared root is reversible.

And the going back is written (ADR-0054). A machine cannot replace the filesystem it is running on, so a restore is not an act but a request: Operation::SnapshotRestore checks that the snapshot is there and writes one line where the next boot’s initrd will read it, and the swap happens beside the unit that makes the root fresh — the one moment nothing is mounted. Snapshots moved to a subvolume of their own at the same path, because putting /persist back means deleting the subvolume it is, and a snapshot kept inside that subvolume would have gone with it: restoring one would have destroyed every other. /persist, /var/log and /home may be put back; / is made again from the blank at every boot anyway, and /nix is what every generation is made of. tacid data list | snapshot | restore is the surface, and it says what a restore costs in the words of what is lost. It is proven in checks.*.install, on a machine that reboots and finds the file as the snapshot says it was.

3.6 Reversibility classifier and taint

Reversibility is computed by the keeper over the rendered option diff, never asserted by the model.

  • Default rule: an option change covered by generation rollback with no entry in the irreversible list is reversible. Unknown options are irreversible.
  • Irreversible despite rollback: exposure (listening services, open firewall ports, sshd, remote agent access), principals (users, groups, sudo, owner key), secrets, boot and encryption (bootloader, LUKS, TPM, Secure Boot), external devices (flash, disk operations on other media), data deletion, git push, anything that sends file content off the machine.
  • Taint: every context item carries provenance (owner, user, system, tool, file, web, mcp:<name>, voice). Tool output derived from untrusted input is untrusted. A turn whose context contains untrusted content is capped at level 1 for side effects; voice-origin intents are capped at level 2 unless confirmed on a physical input.
  • Budgets: transactions per hour, closure delta in bytes, egress mebibytes per day, and egress mebibytes per session. The owner writes all of them in one place, tacid.policy.budgets; they are not all kept in one place, because a budget belongs in the process that can still stop what it counts (ADR-0058). Transactions and closure delta are the keeper’s, fed as it applies a change. Both egress ceilings are the proxy’s, counted over the proxy’s own ledger, seeded from that ledger at startup so a restart does not begin the day again, and refused with a 429 before a connection is opened. The ceiling is overshot by at most the one exchange that crosses it, which is as exact as a proxy that cannot weigh an answer before asking for it can be. The per-session ceiling needed a session that names itself: a turn’s session travels in x-tacid-session, the ledger records which conversation spent what, and a session’s share being spent stops that conversation while the machine carries on (ADR-0071). Cloud spend is deliberately not a budget. It is a provider’s arithmetic over tokens against a price list this machine was told, and enforcing a ceiling on an estimate is enforcing on a guess; what the router reports is cost visibility, which ADR-0016 says is never authoritative. Bytes are the part that can be counted exactly, so bytes are the part that is enforced.

3.7 Policy and autonomy

LevelNameBehaviour
0observeRead-only.
1consultProposes everything; asks for everything.
2reversibleDefault. Acts with notification on changes the classifier proves reversible; asks otherwise. The notification is not the answer to a question nobody asked: the keeper holds every change it allowed without asking until a journalled call says somebody has been shown it (ADR-0057).
3trustedAlso acts without asking on owner-declared categories in mind/policy.nix.
4sovereignEverything except the hard invariants. Persistent indicator on every surface. Expires at reboot or after a configured window. Never entered silently.

Capability-scoped tool visibility: a session’s model sees only the tools it is permitted to use. This keeps small models competent and shrinks the attack surface. The deterministic : verbs and tacid do never touch a model at all — tacid do is built (docs/cli.md): one verb per typed change item, the same transaction through the same keeper with the same preview and the same consent, and no step where a model decides what was meant. It is what a script should use, and what a machine that has never downloaded a mind still has.

3.8 Secrets

  • Declared secrets (API keys, tokens, host keys) are sops-nix/age in secrets/; machine and service secrets are systemd-creds, TPM2-sealed where available.
  • The model sees only handles: secret://anthropic/default. The keeper resolves a handle for a specific destination; the egress proxy injects it into the outbound request; tools receive it in an environment variable scoped to one invocation.
  • A scrubber runs on every tool result, memory write and audit line; known secret values and high-entropy tokens are replaced by handles before anything reaches a model or a log.
  • Protected paths are denied by default: ~/.ssh, ~/.gnupg, credential stores, browser profiles, /etc/tacid/secrets, /persist/etc/ssh.

3.9 Egress

  • The proxy is a unix socket in /run/tacid, reachable through its group. Around it, systemd’s RestrictAddressFamilies = [ "AF_UNIX" ] keeps every Tacid process except the proxy off the network, enforced by the kernel rather than by the process being confined — a seccomp filter on socket(2), so the socket is never made. IPAddressDeny=any, a cgroup eBPF program, stays beside it as a second mechanism for the same promise.

    This is the narrowing four documents used to describe as nftables rules pinning the agent uids to the proxy, and it is stronger than those rules would have been: they would have filtered packets on a socket the daemon was still allowed to create, leaving localhost — every local port — as a relay to narrow rather than a door to close. What made it possible is that nothing needed it: tacid-egress is the only crate in this tree that opens a TCP connection, and the model client, the update client, the keeper, the sessions and the local mind are all Unix sockets. The stem’s nftables.profile operation is gone with it (ADR-0084).

  • The proxy enforces the domain allowlist from policy, injects credentials, records the ledger (destination, data classes: text, file content, screenshot, logs, audio; byte counts; model; estimated cost), and raises the NET badge while a request is in flight. It publishes that as a file — the only way a process with no clients can say something — held by a guard that lives as long as the exchange does, so an early return, a dropped connection or a panic takes the badge down with it. The keeper reads it and answers for it in the same call as the airgap: may this machine talk, and is it talking. What the badge does not yet say is which destination; the panel has the room for that and the bar does not.

  • tacid airgap on raises the airgap and shows a persistent badge. Airgap is a one-command reflex, not a setting buried in a menu, so raising it needs nobody’s permission — the agent may do it too, which matters most: a mind that suspects it has been captured should be able to stop itself. Lowering it goes through the gate consent goes through: the owner’s key, or the person whose home the policy is written against. Not a guest with an account on the machine, and not uid 0 presenting as a person. Both go to the keeper and both are journalled. tacid.core.egress.airgap = true is the declared version: up at every boot, with tacid airgap off lasting until the next one and saying so. The flag the proxy reads is a file in the keeper’s directory, and that is the whole of it: the nftables profile that was to have been its second half is gone, because a machine whose agent processes cannot open an IP socket has nothing left for a firewall to stop (ADR-0084).

3.10 Facts and Doctor

system.* is a structured, typed model of the machine that the agent queries instead of parsing shell output: cpu, memory, gpu, disks, usb, serial, probes, network, services, processes, logs, generations, packages, kernel, session, drift (declared vs. live). Sources: sysfs, udev, journald, systemd’s D-Bus, the Nix store, nvidia-smi/DRM.

tacid doctor <domain> runs deterministic checks over the facts — boot, storage, kernel, services, network, graphics, resources, power, mind, in the order it reports them — and lets the model explain and propose; the checks produce truth, the model produces prose. The health gate reuses the same checks. The domains are what a machine is made of rather than what is installed on it: a list of programs (nvidia, bluetooth, cuda, toolchains) is a list that grows forever and leaves a person guessing which word to type.

3.11 Memory

SQLite with FTS5 and sqlite-vec, vector index versioned by embedding-model id, forward-only migrations with a snapshot at every activation so a generation rollback never corrupts a newer database.

One database, held by the daemon, with layers — not one per person, which is what ADR-0017 first described. The layers settled it: machine is machine-wide by definition and owner is the owner’s, so the layers are the scope and a second axis would have been two answers to one question.

Layers: session (task context), project (facts bound to a repository), owner (preferences the owner allowed), machine (facts and action history). Never-memory: secrets and protected values. Every write carries provenance; memories derived from untrusted content are never loaded as instructions; owner-layer writes need confirmation. Reading is scoped by principal: an integration — something a person plugged in, capped at consult — sees machine and project, the impersonal half, and not what the owner told this machine or what belongs to a conversation it is not in — at both doors, the method a client calls and the tool the model calls on its behalf, from one definition (ADR-0076). tacid memory searches, edits, exports and deletes.

Learning beyond memory: opt-in, sanitized tool-trace collection with a published sanitizer specification and a shown-before-upload bundle feeds tune/, which fine-tunes a small Tacid tool-use model. A small model fine-tuned on Tacid’s own tools beats a large generic one at running this machine. The dataset is licensed CDLA-Permissive-2.0.

The rule the sanitizer follows is identity is substituted, everything else is kept, and a person reads it before it goes — substituted rather than deleted, because /home/<user>/project/.gitignore still teaches what [redacted] does not. tacid.policy.keepTraces is off; nothing is ever sent by the machine; tacid trace export writes a file a person hands over. docs/sanitizer.md is the specification, and the examples in it are executed by a test (ADR-0074).

3.12 The mind: routing and providers

  • Task classes: route, classify, edit, summarize, plan, code, speak, listen, embed.
  • Privacy class is derived from provenance labels and path policy, never from a classifier.
  • Routing inputs: task class, privacy class, policy, context size, latency need, offline state, local hardware, requested model, budget.
  • Providers behind one rich trait: first the Anthropic Messages API and the OpenAI-compatible family (OpenAI, xAI, Google’s compatibility endpoint, OpenRouter, llama-server), then native adapters when a provider’s tool-use, caching or structured-output capabilities diverge enough to matter. No default provider; the installer asks; keys go to the keeper.
  • Every cloud request shows the badge and writes the ledger; cost is reported and never claimed authoritative.

3.13 Local inference

  • Engines are subprocesses, never linked in: llama-server (CUDA; Vulkan on AMD by default with ROCm opt-in; Vulkan on Intel; CPU), and — once each can be reached the way this machine reaches an engine — whisper-server and a Kokoro TTS server. Sandboxed with read-only model files, seccomp, no network. A second Rust backend (mistral.rs) may follow behind the same trait. Both have been read, and they differ: llama.cpp takes a Unix socket as a --host ending in .sock; whisper.cpp takes only a port, so the supervisor holds the socket in front of one it binds on loopback inside the unit’s own namespace. Nothing has read how a Kokoro server listens, so that one is still refused rather than guessed at.
  • Constrained decoding: tool schemas compile to GBNF grammars so small local models call tools reliably (ADR-0056). Built, and it turned out to be the difference between unreliable tool calling and none: llama-server without --jinja never reads the tools field, so the catalogue this machine assembles was being sent and discarded. It now goes in the prompt, and the sampler is held per request to a grammar compiled from the same list, whose root admits one call or one spoken answer — per request rather than server-wide, because what constrains a tool call must not constrain the sentence a person is answered with. The reply is read out of the content, because there is no tool_calls field to read it from. The placeholder that pretended otherwise is long gone; it was wrong in both those ways and pointed at an empty file, which is not a grammar. What is proven: every reference resolves and every rule is reachable, a hostile tool name from an untrusted MCP server cannot introduce a rule, and a constrained reply becomes the same typed completion the remote path produces. What is not: that a real llama.cpp accepts what is emitted. No test in this container starts an engine, and that half stays open.
  • Registry models/registry.toml (signed): id, license, source (the page a person reads), file (the artefact to fetch), hashes, size, RAM/VRAM, backends, context, intended tasks. Profiles tiny (8 GB RAM, no dGPU), core (16 GB RAM or 8 GB VRAM), lab (32 GB or 12+ GB VRAM) recommended from detected hardware. Weights are never in the netinstall ISO; the offline ISO carries tiny; USB model packs serve airgapped sites.
  • Resource governance: models are evicted on memory pressure; Nix evaluation runs in a capped cgroup; inference yields VRAM to owner workloads; tacid models shows disk use.

3.14 Voice

The pipeline is tacid-voice, and everything below except the microphone and the speaker is built and tested (ADR-0075).

Push-to-talk by default (Super+Space held in the shell, a key in the TUI). Wake word (openWakeWord via ort in the session) opt-in with a persistent indicator; audio never leaves the machine before the wake word fires. Streaming partial transcripts into the surface. Interruptible speech. Voice is an untrusted principal. Budgets (core profile, mid-range laptop): release to first partial ≤ 400 ms; 8 s utterance to final text ≤ 1.5 s; first model token ≤ 300 ms (7B Q4 on GPU); first audio ≤ 400 ms after the reply starts.

3.15 Updates and channels

nixos-unstable ──► Tacid CI ingest ──► build matrix (x86_64, aarch64; every reference config)
              ──► VM tests ──► cache fully populated ──► sign ──► edge
              ──► hardware matrix ──► maintainer promotion ──► stable
  • A channel manifest names tacidos_rev, nixpkgs_rev, kernel, tests passed, publish time and expiry, signed with TUF roles: an offline root key in the maintainer’s custody, online snapshot and timestamp keys with short expiry, revocation by manifest. Store paths carry separate cache signatures.
  • tacid-update.timer: poll hourly on edge, daily on stable; build when new; switch only at quiet points (idle, on AC, no active session, or at a scheduled window); boot-class changes wait for a reboot and use systemd-boot boot counting; the health gate must pass or the previous generation is restored. The new lock is committed to the genome.
  • tacid update runs that same check now, for a person who does not want to wait for the hour. It starts the check and nothing more: the command line asks the keeper, the keeper asks the stem, and the stem starts one unit — named by a constant of its own protocol, because an operation that took a unit name would be root starting anything (ADR-0077). The verification stays in tacid-update, which is core and channel-signed: a daemon that fetched a manifest and believed it would be the agent deciding what the machine becomes.
  • Kernel: edge tracks the newest stable series; stable tracks the newest series validated by the hardware matrix; LTS is available for servers and boards.
  • Generation retention: the last ten, everything from the last thirty days, and anything pinned — declared by the owner under tacid.core.retention, applied by the keeper from its own policy, carried out by the stem by number (ADR-0040). A generation the bootloader’s counter passed over is not one of the newest kept, nor kept for being young — on a machine following a channel the newest generations are the ones most likely to be bad, and an abandoned one is always young because it was made moments before it failed (ADR-0065). The collection runs weekly, or when the owner says, once nobody is at the machine. Models are not store paths and are never collected.
  • Binary cache: Attic behind a CDN; cache.tacidos.com/stable and /edge.

3.16 The immune system

Owned by the keeper and the bootloader, outside the agent’s reach:

  1. Pre-switch checks: evaluation and build succeeded; closure delta within budget; activation class allowed now.
  2. Post-switch gate within a window: boot-complete.target reached; tacidd, keeper, egress and (if graphical) the shell are up; declared services healthy; network reachable if declared; inference answers a stub prompt; the agent’s tool-use smoke test passes. “Up” is read from the kernel’s own accounting — a unit whose cgroup still holds a process — and not from systemd’s invocation files, which it keeps for a unit that started and failed. On a machine where that cannot be read at all, the gate skips rather than fails: a machine that cannot be measured has not failed, and rolling a good generation back for want of a measurement would make the immune system worse than none.
  3. Failure: re-activate the previous generation, restore the matching state snapshot for Tacid’s own databases, journal the failure with logs, and notify every surface.
  4. Boot-class failures: systemd-boot counters mark the entry bad after the configured tries and the previous generation boots.
  5. The owner can always select any generation from the boot menu and always reach a tty.

3.17 Self-improvement

  • Tacid’s source is one input of every genome. At autonomy level 3 or higher, the agent may check out tacidos, make a change, run the test suite, build this machine with --override-input tacidos ./checkout, switch, and verify.
  • The crates are partitioned. Core (tacid-stem, tacid-keeper, tacid-protocol, the health gate) is channel-signed; the keeper verifies the core’s store signatures before the stem activates, and only the owner may replace core. Everything else is agent-mutable. A machine running self-built code is marked and cannot claim stable. The health gate in that list is tacid-doctor over tacid-facts, and both sat on the mutable side until ADR-0081 — which is why the arrow between the two sides is now a test and not a list.
  • The agent never opens pull requests. It files a proposal (branch, CI results, evaluation results, a plain-language rationale) into the owner’s queue. The owner reviews, signs (Developer Certificate of Origin), and pushes. Species evolution is proposal → maintainer → CI → channel → every body.
  • Hardware quirks discovered on one machine become proposed hardware modules for all: tacid propose body composes a nix/hardware/ module out of the machine’s own firmware identity and the tacid.* lines its body settled on, and files it like any other proposal (ADR-0073). Boards that cannot build Tacid use a workstation body as a remote builder.

3.18 Storage, boot, impermanence

GPT and UEFI only; systemd-boot; systemd stage-1. LUKS2 on by default with TPM2 + PIN bound to PCR 7 (Secure Boot state) so hourly kernel updates never lock the owner out; a recovery key shown once at install. Btrfs with subvolumes @root (ephemeral, reset at boot), @persist, @home, @nix, @log, and @snapshots mounted at /persist/.snapshots — outside the subvolume it holds snapshots of, because putting that one back deletes it (ADR-0054). Impermanence is the default: the machine is a function of (genome, persist); every tacid.services.* option declares its persistent directories; the agent watches for writes to ephemeral paths and offers to declare them. Secure Boot is Lanzaboote, behind tacid.core.boot.secureBoot.enable, and it is four steps rather than a switch: keys, the declaration and a rebuild, one reboot to prove the signed chain boots, and then tacid secureboot enrol — which is refused unless the machine already signs what it boots, because a firmware told to verify an unsigned chain refuses the very generation that told it to (ADR-0080). Microsoft’s certificates are enrolled beside the machine’s own so NVIDIA option ROMs keep working. It is off by default and stays off until the certified matrix passes, and the documentation says plainly that NixOS does not enforce kernel-module signing. Swap: zram by default, a swap file when hibernation is requested.

3.19 Networking

iwd, systemd-networkd and systemd-resolved. The agent and the shell are the network UI: tacid network says what the machine is on — the kernel’s up and networkd’s routable are two different answers and it gives both — and join/forget provision iwd through the keeper and the stem, with the passphrase read from standard input and registered with the scrubber before anything is written down. Scanning is built and asks the kernel directly: system.wireless is a NL80211_CMD_GET_SCAN dump over generic netlink, which returns the kernel’s cached list of what each radio last heard and needs no privilege to read — a fresh sweep is CAP_NET_ADMIN and is not asked for (ADR-0085). Each name comes back once, at its strongest signal, with how many access points carry it, what it wants — open, a passphrase, an identity — and whether this machine is on it. VPNs and WireGuard peers are declared. Firewall on by default; SSH off unless selected; the agent socket is never exposed beyond the machine by default; remote access is SSH-first with no relay.


4. The tacid.* option surface

The agent does not write arbitrary Nix. It edits typed options with descriptions, examples and validation whose failures are messages a model can act on.

ModuleAgentContents
tacid.corereadEgress, persistence, retention, the update timer, and the daemons’ own units and sandboxes.
tacid.owner / tacid.policyreadWho the owner is and their key; autonomy, capabilities, allowlists, budgets. Owner-signed only.
tacid.packageswritePackage lists by category: cli, gui, dev, embedded, ai.
tacid.serviceswriteAllow-listed services with curated options. Built: OpenSSH, Tailscale, Syncthing, Podman, libvirt, Bluetooth, printing, Flatpak, nginx — nine, each of which carries the configuration enabling it means, so a name with nothing behind it does not evaluate (ADR-0078). It said eleven for a while: WireGuard and local inference were in the list and nothing implemented either. Every service declares its persistent state and whether enabling it makes the machine answerable from somewhere else, and the machine carries the list as services.json so a person, a model and the keeper’s classifier read the same one.
tacid.hardwarewrite, approvalCurated udev profiles and the GPU profile. Raw udev rules need review.
tacid.desktopwriteThe session, theme, wallpaper, fonts, keybindings, autostart.
tacid.devwriteToolchains.
tacid.userwriteShell, packages and non-privileged groups for the person. Tacid does not depend on Home Manager; owner.nix may import it.

Rules: every option has description, example and a type or assertion; the agent’s edits touch only mind/agent.nix — rendered from mind/agent.state.json, so what it asked for and what Nix reads cannot disagree — and pkgs/; evaluation errors are returned to the model verbatim; a module is not merged without a VM test.

The escape hatch exists and is not an option. ADR-0008 kept free Nix for level 3 and above as tacid.raw, an option in a file of its own. What was built is ChangeItem::RawNix: a change item with its justification attached, refused for any agent at any autonomy level, and rendered into mind/agent.nix with that justification as the comment above it. Three differences from the record, each deliberate — a change item goes through the classifier, the preview and the journal, which an option written directly does not; the owner’s access is not gated on the agent’s autonomy level, because that level says how much the agent may do unasked and not how much authority the owner has; and it shares the agent’s file rather than having one of its own, since what makes it visible is the comment and the git log, not the filename. owner.nix remains the other escape hatch, and the one to reach for first: free Nix the owner writes and the agent never touches.


5. Surfaces

5.1 CLI

Every verb, what it does, and the verbs there are not, is docs/cli.md — kept there rather than here because it is a document a test executes: the CLI asks clap what commands it has and holds the page to it, in both directions. Every command accepts --json, and the shell uses the same protocol and nothing more.

5.2 TUI

 tacid  atlas   edge 2026.09.02-1842   mind: local/qwen3-8b   NET off   gen 187   level 2   3.2G/16G
 ┌ conversation ────────────────────────────────────────────────────────────────────────────┐
 │ you     ship a static site from ~/site to /srv and keep it running                       │
 │ tacid   plan: 3 steps · 1 change (reversible) · 0 approvals                              │
 │   ✓ facts.services nginx                       not installed                             │
 │   → change  tacid.services.nginx = { enable = true; root = "/srv/site"; }   [diff]       │
 │   ○ verify  GET http://localhost/ → 200                                                  │
 └──────────────────────────────────────────────────────────────────────────────────────────┘
 :                                                                approvals 0   journal   ⏺ ptt

Readable at 80×24 in 16 colours on a serial console; better with more. Tool traces collapse and expand. Approval prompts are modal and keyboard-only: y once, n, s scope this session, a always for this category (owner key). : verbs never touch the model: :diff, :undo, :gen, :update, :airgap, :mind, :policy, :sh (a real shell, also a tool the agent can request), :q.

5.3 Shell

tacid-shell, on Hyprland — one compositor, no option (ADR-0053):

  • Bar: the workspaces and the focused window’s title at the left, then hostname, generation and channel; then, at the right, what this machine can reach, the battery, the clock, what is waiting for an answer, what the machine did while nobody was here, and the badges.
    • Three things are drawn only when they say something, which is the same argument each time. A wired machine draws nothing about its network — wired on a desktop for a year is furniture — a wireless one draws the network’s name, and one that can reach nothing says offline. A machine with no battery draws no charge. A machine with no compositor draws no workspaces.
    • Four of the five badges are drawn: SOVEREIGN, because ADR-0009 requires a persistent indicator at level 4; NET — drawn as sending — because the manifesto says nothing leaves without a badge, and the proxy now publishes what is in flight for the keeper to answer for; AIRGAP, because ADR-0011 promises the switch and a badge, and a machine somebody has deliberately silenced must not look like one that merely lost its network; and SELF-BUILT, because a machine running code it built itself cannot claim a channel’s guarantees.
    • MIC is not, and the reason changed rather than went away. A microphone does open now — Super+ ` holds one, through tacid voice hold — but it opens only while a key is held, and the indicator for that is the hand on the key. A badge that appeared and vanished with a keypress would be a badge nobody could ever catch. It is named here rather than implied by a list, and it is what a wake word would need before one could be built (§12).
  • Command surface (Super+Space): one box for launching, searching and asking; the user never decides whether something is “chat”. Deterministic matches first; the agent answers below.
  • Agent panel: goal, plan with live tool state (never theatrical progress), system changes with summary/diff/log tabs, approve/reject bound to the transaction id, pause and stop.
  • Notifications: Tacid’s own are a count on the bar, which is already on the screen; the standard protocol, for other programs’ messages, is open.
  • Settings: curated product concepts (System, AI, Developer), never every Nix option.
  • Recovery at boot: generations with plain-language labels (“Before CUDA setup”), inspect changes, boot selected, make default.

Terminal: Ghostty with OSC 133 shell integration so terminal sessions become structured objects (command boundaries, exit status, working directory) that can be sent to the agent on request, never scraped. Browser: Firefox, unmodified, with an explicit “send page to Tacid” later.

5.4 Installer and first boot

Two artifacts: netinstall (≤ ~1 GB: kernel, installer, TUI, daemons, no graphical closure, no weights) and offline (several GB: the reference workstation already built, in the store on the medium, so an install with no network copies rather than compiles — plus, when a verified hash exists for one, the tiny model so the first boot is not mute). The installer is not separate code: it is the stem, the keeper and tacidd running in genesis mode on the live system, driven by the TUI over the console or a serial line, and its output is the first genome and the first generation.

Welcome to Tacid
 1 Keyboard and language
 2 Network (optional)
 3 Disk (the plan, previewed)               6 Autonomy level (default: reversible)
 4 Encryption (on; TPM2 + PIN; recovery key) 7 Mind: local profile sized to hardware; optional cloud key
 5 Owner key and user                        8 Review → Install

Hardware profile is auto-detected with an explicit GPU choice on NVIDIA machines (CUDA-ready, graphics only, or fully open). What a person knows of the body that no probe finds out goes in at birth — tacid install --body <file>, written under the genome’s body/ and imported from owner.nix — so the first system built is the one the machine will keep building.

Step 1 is built except for one thing, named at the end of this paragraph. The keyboard is asked (tacid install --keyboard de --keyboard-variant nodeadkeys) and written into body/hardware.nix as tacid.hardware.keyboard, from which one option reaches the virtual console, the compositor and — because systemd is in stage 1 — the initrd, so the prompt that asks for the disk’s passphrase is on the person’s own layout. Nothing finds this out: a kernel reports the same scancodes whatever is printed on the keys. The language is asked the same way (tacid install --locale de_DE.UTF-8) and written into owner.nix as tacid.owner.locale, from which it reaches i18n.defaultLocale, /etc/locale.conf and so LANG — the console, the greeter, the session and every service. Beside the owner’s name and key rather than beside the keyboard, and the split is deliberate: a keyboard is a fact about the body, what is printed on the keys of the machine in front of somebody, so a genome carried to another machine leaves it behind; a language is a fact about the person, and travels with them. This paragraph said “every machine is C.UTF-8” until somebody asked one: with no option and nothing set, every machine took nixpkgs’ default and was en_US.UTF-8, which is now the option’s default too — so adding it changed no machine.

What is still not finished is the live image’s own console, which is us while the passphrase is composed, so a person who installs on a German keyboard and encrypts the disk types the passphrase on the image’s layout and gets the installed machine’s. tacid install says so before it prints its plan; fixing it is a decision rather than a piece of work, and it is set out in the build log. First boot: the agent introduces itself, verifies hardware against the facts, offers model downloads, and shows Super+Space (or the TUI prompt) with one suggestion. Also supported: nixos-anywhere over SSH, and “Tacid on your existing NixOS” through inputs.tacidos.nixosModules.default.


6. Developer and embedded workflows

  • Projects: detection (Cargo, npm, CMake, PlatformIO, Zephyr, ESP-IDF, Python, Go, Nix); generated dev shells with direnv; never rewrite an existing flake.nix, shell.nix or devenv.nix; optional .tacid/project.toml for per-project policy and embedded targets.
  • Git: typed tools (status, diff, log, show, branch, worktree, commit); destructive operations (reset --hard, force push, deleting untracked files) are classified irreversible.
  • Editors: Neovim, Helix, Zed, VS Code available; “ask Tacid about selection”, open at location, agent-produced patches. No IDE of our own.
  • Containers: Podman by default, Docker available, devcontainers, distrobox. App-source ladder: nixpkgs → Tacid module → upstream flake → Flatpak → container → wrapper.
  • Device center: a USB/serial/probe graph (VID/PID, serial, interfaces, driver, permissions, inferred capabilities, project association). On connection: a quiet notification with “open” and “use with current project”.
  • Probes and targets: ST-Link, J-Link, CMSIS-DAP, Raspberry Pi Debug Probe, Black Magic Probe; OpenOCD and probe-rs; STM32, ESP32, nRF, RP2040/RP2350, Zephyr boards. tacid flash, tacid serial, tacid probe as transactions with exact target identity; UART capture and crash summaries; map-file comparison.
  • Datasheets: local PDF extraction in a sandbox, page-aware retrieval with citations, upload only with permission.
  • Terminal and hardware together: the agent can hold source, build output, debugger state, serial log and target identity in one context and answer “why does it reset when the motor starts”.

7. Design system and budgets

  • One source of truth: design/tokens.toml (colour scales, one accent, type scale, spacing, radii, motion durations, and the glass: one opacity, one blur, one shadow). Generators produce the TUI theme, the Iced theme, the terminal palette, the Hyprland configuration, the lock screen’s, the notification daemon’s, and palette-level GTK and Qt themes (no more is honoured) — and the boot screen, which was the last surface outside the system and is ADR-0089. A boot screen also needs a device to draw on, which it has in stage 1 only if its driver was in the initrd, so tacid.hardware.gpu.profile puts the driver for the card it names there and doctor boot catches the machine that named none (ADR-0090) — the failure it prevents is silent, every unit succeeding while nothing draws.
  • Typography: Inter for interface text, JetBrains Mono for code and the TUI. No font soup, no patched icon fonts; a proper icon set. Behind them, and never in front of them, Noto — the face that catches everything this machine did not write: emoji, CJK, and every script Inter does not carry. Two fonts was not restraint, it was a browser drawing boxes.
  • Tone: quiet, precise, dense when needed. Dark and light are equals. Motion only when it explains a state change. No neon, gradients, sci-fi noise or giant chat windows.
  • Material: rounded and translucent, on the machine’s own chrome and nowhere else. Windows are rounded at the surface radius and the compositor blurs what is behind Tacid’s layer surfaces and the notification daemon’s, so the strip along the top and a notification are the same material. A person’s work is not chrome: unfocused windows are not faded, and the lock screen is not a blurred photograph of the desktop — at any blur worth having you can still read a video call from a bank page off the shapes, which is what locking a screen was for. A surface anchored flush to a screen edge stays square, because a rounded corner against the corner of a screen is a notch with the wallpaper in it; the surfaces that float are rounded. tacid-shell draws its own surfaces at the same opacity, which took two records to get right: ADR-0087 found that its software renderer handed Wayland a buffer with no alpha channel and accepted an opaque shell; ADR-0088 checked that record’s three reasons, found none of them standing, and moved the shell to one renderer that carries alpha. checks.*.face photographs the composite and predicts it from the tokens, on a machine with no graphics card.
  • The boot: this machine’s ground, this machine’s name, and one line under it saying what systemd is starting or what a disk is waiting for. Text and nothing else — no image in the theme and none loaded from one, so every glyph is set in the interface face and a change to the token file reaches all of it. No spinner and no progress bar: plymouth’s own is scaled by the duration of the previous boot and is therefore fiction on the boot where it would matter most. Where a root is encrypted, which is the default, this is where the passphrase is asked for — with a dot in the accent per character and a caps-lock warning that is polled because plymouth never announces one. The log is moved rather than hidden: Escape shows it, in these colours, on this ground, and journalctl -b has every line either way. checks.*.boot renders the theme under an X server in about thirteen seconds and measures the ground to the byte.
  • Published budgets, measured in CI on the reference laptop: power-on to prompt ≤ 8 s (TUI) and ≤ 12 s (shell); idle RAM ≤ 400 MB without a loaded model; tacid diff ≤ 200 ms; a no-op apply ≤ 1 s; input lag imperceptible; the voice budgets in §3.14.

8. Security

The invariants, trust zones, threat model and key custody are in SECURITY.md and are normative. This section adds the engineering matrix.

SurfaceMechanism
Daemonssystemd hardening: ProtectSystem=strict, PrivateTmp, NoNewPrivileges, RestrictAddressFamilies, SystemCallFilter, IPAddressDeny where applicable, dedicated users, socket activation.
Spawned toolsbubblewrap + Landlock + seccomp; scoped mounts; no network unless the policy grants it, and then only via egress; time and memory limits.
Untrusted parsersPDF, archives, images, web content parsed in sandboxes in the user’s session, never in tacidd.
High-risk codemicroVM via microvm.nix (Firecracker or cloud-hypervisor) with no devices unless declared.
InferenceSubprocesses with read-only model files, seccomp, no network; embedded templates ignored; signed registry.
NetworkRestrictAddressFamilies = [ "AF_UNIX" ] on every Tacid unit but the proxy — a seccomp filter on socket(2), so an agent process does not get an IP socket to misuse — with IPAddressDeny=any beside it as a second kernel mechanism. Stronger than the nftables pinning this row used to promise, and it replaces it (§3.9, ADR-0084). Airgap is built and does not depend on either.
Supply chainSigned tags; SLSA provenance for CI artifacts; SBOM per release; reproducibility checks on the core; pinned inputs; signed channel manifests (TUF roles) and signed cache; dependency review on every bump.
TestingPrompt-injection corpus as a CI gate (zero side effects at level 2); fuzzing of the stem protocol and the scrubber; permission regression tests; OVMF + swtpm harness for Secure Boot and TPM paths.

9. Hardware program

  • Order: x86_64 UEFI first; aarch64 UEFI second; riscv64 aspirational; phones far future.
  • Graphics: NVIDIA (CUDA, open kernel module where supported, PRIME hybrids, suspend/resume, external displays, explicit sync), AMD (Vulkan by default; ROCm opt-in), Intel (Vulkan). No claim of support before the physical matrix passes; physical NVIDIA CI before edge is public.
  • Tiers: certified (physically tested by the project), supported (upstream support with active triage), community (reports exist, no promise). Published at hardware.tacidos.com or in the docs.
  • Reference machines (to be acquired): an Intel laptop, an AMD laptop, an NVIDIA desktop, an Intel/NVIDIA hybrid laptop, an AMD/NVIDIA hybrid laptop, an aarch64 UEFI machine, an ARM board for the headless profile, a set of debug probes and boards for the embedded bench.
  • Apple Silicon: a separate community channel built on the Asahi enablement stack, limited to models with mature support; never a general “runs on MacBooks” claim; the installer never touches APFS.
  • Boards: headless Tacid nodes on selected SBCs; a workstation body serves as their remote builder; no desktop promise on arbitrary boards.

10. Release engineering

  • Versioning: components SemVer; stable releases CalVer YY.MM; channel revisions YYYY.MM.DD-HHMM; tacid version prints all of it.
  • Channels: dev (from branches, no promise), edge (automatic on green CI and a populated cache), stable (maintainer promotion after the hardware matrix).
  • Quality gates for stable: every reference configuration builds on both architectures; shell, agent, keeper and stem build; docs build; privileged API tests, permission regression tests and the injection corpus pass; the release is signed; a clean install boots; an update from the previous stable succeeds; rollback succeeds; the recovery generation boots; login, command surface, notifications, settings and multi-monitor smoke tests pass; local inference, one cloud adapter, tool calling, denial policy and the journal work; on NVIDIA machines the driver loads, the Wayland session works, CUDA inference works and suspend/resume status is documented.
  • Infrastructure: Attic cache behind a CDN; x86_64 and native aarch64 builders; a continuous-integration signer with short-lived keys; the maintainer’s offline root key.

11. Phases

Each phase lists work items with acceptance tests, sized for an agent working under review. Durations are ranges for one maintainer plus agents; they are estimates, not promises.

Where this stands. Phase 0 is complete and Phase 1 is substantially complete: the protocol, the classifier, the keeper with its invariants, the journal, the renderer, the stem, the agent loop with its tools, the fact API, memory, the doctor and the health gate it shares, the egress proxy, the cloud adapters, the signed-manifest update system, the model registry and inference supervisor, the self-improvement partition and proposal queue, the disk layout and genesis genome, the untrusted-content sandbox, the CLI, the terminal interface and the design system are written and tested, with no warnings; README.md carries the count, measured, and the commands that produce it. The Phase 1 exit criterion runs end to end, and the injection corpus is a gate of its own: fourteen cases in which the model obeys the attacker completely and the machine changes nothing. What remains in Phase 1 is a real provider on the other end of the proxy, and a real machine: the Nix builder and the stem’s switch are no longer stand-ins — an installed machine has built its second generation from what it carries and taken it back (ADR-0037). The process split is real since the first boot: the daemon holds no journal and no token and asks the keeper for everything over the keeper’s socket, consent has a wire path (tacid approve), and every socket in /run/tacid is a socket unit’s (ADR-0032).

What a tick means here, since the document had a rule it then broke eight times: an item is ticked when a machine that booted has been watched doing it — a VM under software emulation counts, hardware has not been reached, and a passing unit test does not. Most of Phase 1’s items are therefore unticked while being written, tested and in daily use, which is the conservative direction and is worth the confusion it causes exactly once. The italic notes under each item say what is actually built, and they are the part to read.

So the notes now carry a rule of their own: an unticked item with no note under it is one that is not built. That was not true when the notes were an occasional aside — most items had none, and a reader could not tell “built and not yet watched” from “not started” — and making it true meant writing a note under every unticked item that is built or half built, including the ones where what is missing is the interesting part. A few say the surrounding phase is further along than the item is; Phase 7 has no notes at all, and means it. The repository layout in §12 marks which crates exist and which are still a plan; where this document and the code disagree, the code is right and this document is behind.

Phase 0 — Genesis (2–4 weeks)

Goal: the repository exists, the thesis is tested, and every decision is written.

  • The spike. A ~200-line script that takes a natural-language request, calls a model, edits a tacid.* option in a scratch NixOS configuration, runs nix build, shows the diff, and switches on a developer’s NixOS machine. Accept: “install ripgrep” works end to end and a 60-second recording exists. Never written, and overtaken. The spike existed to answer one question — can an agent change a NixOS machine through a typed option and have it come back — before anything larger was built on the assumption. The answer arrived from the other end: a booted machine now does it across three processes with a policy verdict, an approval, a health gate and an undo, which is the same question answered harder. What is genuinely missing is the recording, and that is a real gap of a different kind: there is no thirty seconds of this working that somebody could watch.
  • Repository layout as in §12 and ADR-0025; flake.nix exposing nixosConfigurations for a reference x86_64 and aarch64 host, packages, devShells, checks, formatter. Accept: every output of the flake resolves to a derivation on both architectures in CI, and the package, the shell and the five second-long checks are built — face, session, documents (every relative link between the documents resolves), profiles (every profile this flake offers evaluates against the least body a machine can have) and tune (the corpus builder’s own tests). Not nix flake check: it builds the four checks that boot machines, which on a runner with no KVM is longer than the next push takes to cancel it. The layout is built and is what §12 describes. The acceptance is not met and cannot be from here: it says “in CI”, and no continuous-integration job has ever been picked up by a runner (below). The evaluation is done on this machine instead, which is a different thing said plainly rather than a tick.
  • tacid-protocol v0 with JSON Schema published as a build artifact. Accept: schema round-trips every message in tests. The crate is built and every message derives schemars::JsonSchema — which is what lets the same schemas narrow a proposal and compile to a grammar a local engine is held to (ADR-0056). Publishing them as a build artifact is not built: no flake output writes the schemas out, so nothing outside this repository can read them.
  • Design tokens v0 and the generators for the TUI theme and terminal palette. Accept: one change to the accent propagates to both outputs in a test. Built, and wider than the item asks: tacid-design generates the terminal theme, CSS variables, Ghostty, Alacritty and the compositor’s colours from one token set, and the generated compositor configuration is handed to Hyprland’s own parser on every push.
  • CI: evaluation, Rust build and tests, nixfmt/rustfmt/clippy, a NixOS VM boot test. Written and never once executed, which is a worse state than not built and is why it is said here rather than only in the notes below. The workflows exist and do the right things — every flake output forced to a derivation on both architectures, the package and the fast checks built explicitly, the machines booted by a workflow of their own, a job that fails if the two images are the same image, a job that reads every link between every document. Every run at every commit has had all four jobs complete two or three seconds after creation, with no runner_id and a 404 for every log. No runner has ever picked one up. The checks are run on this machine instead, which is what “green” means anywhere in this document.
  • All ADRs merged; CODEOWNERS; org and crate names reserved; trademark search done. The records are merged and then some — seventy-nine of them, each saying under what a claim is proven and, where it is not, that it is not. The other three are the maintainer’s and none is done: there is no CODEOWNERS, no crate name is reserved, and the trademark search against the existing “TacID” marks has not been made. That last one is the only item in this phase that can cost money later rather than time.
  • tacid --version prints provenance from a real generation. Built, and read off booted machines — it is how the lifecycle leg knows which revision it is looking at. Ticking it waits on the one field that is not yet true anywhere: the self-built marker, which the daemon passes as false on every real machine because nothing can yet put a machine on code it built itself.

Exit: the reference configuration builds on both architectures; the spike installs a package end to end; every open question from the three source plans maps to an ADR.

Phase 1 — Spine (8–12 weeks)

Goal: the deterministic change path exists with authority, audit and rollback, on a developer’s NixOS machine.

  • tacid-stem with activate, set_boot_default, reboot, bless_boot; keeper-only socket; fuzzed protocol. Accept: a malformed request is rejected; a non-keeper peer is refused. Built, and the peer half of the acceptance is proven over the real wire: crates/tacid-keeper/tests/stem_wire.rs starts a stem and watches it refuse a wrong token, a stranger, and a guarded operation with nobody present. A booted machine watches the same refusal from the other side — the lifecycle leg’s daemon cannot open the stem’s door. The fuzzing is not built: there is no fuzz target for the stem’s protocol, so “a malformed request is rejected” rests on hand-written cases.
  • tacid-keeper: policy engine, the reversibility classifier with the irreversible list, taint tracking, approvals bound to transaction ids, the audit sink, the health gate and automatic rollback, snapshots. Accept: an injected failing generation is rolled back within the window; approvals presented through tacidd are rejected. All of it built, and both halves of the acceptance watched on booted machines: the update leg’s follower is given a generation whose daemon cannot start and the bootloader’s counter abandons it, and the lifecycle leg watches the agent’s own uid refused when it tries to approve its own transaction.
  • tacidd: sessions and principals, the agent loop, transaction rendering, staging and evaluation, nix build in a capped cgroup, facts v0 (services, packages, generations, drift). Built, and no longer a stand-in: an installed machine has built its second generation from what it carries and taken it back (ADR-0037). The capped cgroup is not built — the build runs under the daemon’s own unit and nothing bounds its memory or processors beyond what that unit does, so a build that asks for everything gets it.
  • tacid-egress: allowlist, credential injection, ledger, badge, airgap. Accept: tacidd cannot reach any host except through the proxy; a cloud request succeeds without tacidd ever holding the key. Built, both halves. crates/tacid-model/tests/through_egress.rs sends a request that arrives at a provider carrying a credential the client never had, and the lifecycle leg watches a registered value reach the proxy and never appear in the journal, and the day’s budget reach the process the bytes pass through. What is not proven is any of it against a real provider with a real bill.
  • Models: the scripted stub model for CI; the Anthropic adapter; the OpenAI-compatible adapter; record/replay cassettes. The stub and both adapters are built and are what every test runs against. The cassettes are not: there is no recorded exchange with a real provider anywhere in the tree, so nothing here has ever been checked against what a provider actually sends back.
  • tacid CLI: ask, do, status, diff, apply, undo, history, generations, rollback, version, all with --json. Built, --json as a flag on the command rather than on each verb, and watched on a booted machine: the lifecycle leg types the verbs somebody types on their first day and reads the answers.
  • Self-build: tacid dev build --override-input tacidos ./checkout for the agent-mutable crates; the self-built marker. Accept: a patched tacidd runs on the developer machine. The half that decides what the agent may touch is built and is the strict half: the partition (tacid-propose), the queue, source.write refusing any path on the core side, source.propose, and tacid propose list/show/sign. The half that builds is not. tacid dev build is not a command, so nothing can put a machine on code it built itself — and the self-built marker, which is rendered in the version output, the terminal and the bar, is passed false by the daemon on every real machine. It is a light that has never once come on, which is worse than one that is missing: somebody will read its absence as an answer.
  • Injection corpus v0 as a CI gate. Accept: zero side effects at level 2 across the corpus. Built and green — fourteen cases, each a completion in which the model obeys the attacker completely, covering every untrusted provenance the machine has including an integration and a third party’s MCP server. It is a test and not yet a gate, for the reason below: no continuous-integration job has ever been picked up by a runner.
  • VM tests for the whole loop with the stub model. Built and green. The lifecycle leg boots a machine under software emulation and runs twenty-five subtests over it, from the sockets’ owners and modes to a change carried out across three processes, an approval refused and then given, and the journal verifying with all of it inside.

Exit: “install Wireshark and let me capture packets without root” produces a summary, an option diff, an approval, an activation, a verification and a one-command undo on a developer’s NixOS machine, with the injection corpus green.

Phase 2 — Senses (6–8 weeks)

Goal: the machine can be talked to, offline, and knows itself.

  • TUI with the layout in §5.2, the : verbs, modal approvals, serial-console readability. Built and in daily use — tacid with no arguments is it, the : verbs never reach the model, and approvals are modal and bound to a transaction id. Watched on a booted machine: the lifecycle leg approves and denies through it. What is not done is the readability claim, which needs somebody at an 80×24 serial console rather than a test.

  • tacid-infer: sandboxed llama-server and whisper-server; the OpenAI-compatible local socket; eviction on pressure. Constrained decoding is written (ADR-0056); what is left here is an engine to try it against. Accept: a 7B model calls a tool with a valid schema 100 times out of 100 in the eval set. The supervisor is built and is careful about what it is supervising: an engine is a C++ program that parses a hostile binary format, so it runs as a subprocess of its own and never in this address space, is not resurrected forever when it keeps dying, and is stopped when its supervisor goes. The registry, the profiles and the sizing are built. No engine has ever been started by any of it, because no model file has ever been fetched — so the OpenAI-compatible local socket, eviction under pressure, and the acceptance itself are all waiting on the same thing: somebody downloading a file and writing down its hash.

  • Registry and profiles: models/registry.toml, tacid models, hardware-sized recommendation, verified downloads. The registry, the profiles and the sizing are built, and tacid models is the surface over them: it reads the registry the generation carries, asks the kernel how much memory this machine has, and says which minds would run here — what a profile would install, what else fits, and what is too large with the reason, since “so where is the 7B” is the next question. Answerable with no daemon running, because the moment a person most wants to know what their machine can run is before anything is running on it.

    *And the download is built: `tacid models --install <name>` fetches by **name** out of this
    machine's own registry — never a URL, because a caller who could name one could name any and
    the hash would then be checking the bytes against whatever that caller also said — through
    the egress proxy, streaming, hashed as it is written, with a wrong file deleted rather than
    resumed
    ([ADR-0079](/decisions/0079-a-name-out-of-this-machines-own-registry-is-not-a-url)).
    Three verifications, none trusting the others: the placer as it writes, the keeper before it
    records, the supervisor before it loads. Proven against a fake file server on a real proxy
    socket, redirects included, each hop of which is a fresh request through the proxy so every
    host is on the allow-list and in the ledger.*
    
    ***And it refuses every entry in the shipped registry***, *because none has a verified hash or
    a `file` to fetch — the artefact's URL, which is a different field from `source`, the page a
    person reads. Both are filled in by the same act: the maintainer downloads the file and
    checks it. A test holds every entry to having neither or both, so half of that act is not a
    state this registry can be left in.*
  • Voice: push-to-talk in the TUI, streaming partials, Kokoro TTS, interruption. Accept: the §3.14 budgets on the reference laptop. The pipeline is built, a real card is opened and a key holds it (ADR-0075). tacid-voice holds the push-to-talk state machine, and the rules in it are the ones whose absence is a failure rather than a shortfall: the microphone is open exactly while a key is held, with the indicator being that state rather than a flag beside it, so listening and saying so cannot come apart; an utterance is bounded at thirty seconds, because a stuck key is otherwise an open microphone until the machine is rebooted; speech yields to speech, in both directions; and interrupting is the one operation that can never be refused. The four §3.14 budgets are constants a turn measures itself against and reports, rather than numbers a document asserts. Capture and Playback now have implementations that open a device — Microphone and Speakers, over ALSA — and checks.*.voice boots a machine with snd-aloop and watches the microphone open exactly while a key is held, close when it comes up, close on its own when the key never does, and hand back what was played into it. The key is Super and the backtick in the Hyprland session, a bind down and a bindr up, which checks.*.desktop asks the running compositor rather than the file. whisper.cpp is an engine the registry names and the supervisor can start, through a door it holds in front of the port whisper binds, and since ADR-0091 there is a client for it: Transcriber is a trait, Transcript says Said or Unasked, and the request is asserted against a real HTTP server on a real socket. What is left is one artefact. No weight in the registry has a verified hash and a file to fetch, so no machine here has spoken to a real whisper-server and every turn ends in a loudness rather than in words; no partial has been streamed; and Kokoro is unread and unstarted.

  • Memory v1 with provenance, never-memory, tacid memory. Built: one database with layers rather than one per person, reads scoped by principal so an integration cannot see the owner’s, provenance on every write, never-memory for secrets, and tacid memory over it. Watched on a booted machine by the lifecycle leg’s memory subtest.

  • Facts v1 and Doctor for nvidia, wayland, audio, network, nix, disk. Built, and the doctor’s checks are the same ones the health gate runs, which is the point of having built it that way: a machine that rolls itself back for a reason its own diagnostics call fine is a machine nobody can debug. A booted machine runs both.

  • Journal and unit tools: journal.read and service.status, the logs.read capability, and the rules that answer “why did sshd fail” offline (ADR-0043). Built, with a lifecycle assertion against a unit that fails on purpose.

  • Tool-use eval set v1: install a service, edit a file, explain a journal, diagnose a failing unit; scored per model in CI. The set and the scorer are built — crates/tacid-daemon/tests/corpus/tooluse.toml holds the four tasks PLAN named and four more, each with a criterion a machine can check rather than a judgement about an answer, and the three criteria are proposes, calls and speaks so that a mind is not measured by one question asked eight ways. What runs in this repository is the scorer and not the eval, deliberately: scoring a scripted model would be scoring the script, so what is proven is that the scorer discriminates — a correct mind scores every case, a plausible and wrong one scores none and is told which criterion it missed, a near miss is a miss, and a tool called after the answer does not count. Scoring a real model is not built: it needs a provider and a key, which is the same gap as everything else on this list that waits on one.

Exit: with the network cable out, a fresh session reads the machine and answers correctly; a push-to-talk round trip lands within budget; tacid doctor covers every domain a machine has.

Phase 3 — Body (8–12 weeks)

Goal: install from an ISO, then never reinstall.

  • Images: netinstall and offline ISOs; a raw image for mini PCs. Accept: the netinstall ISO is ≤ 1 GB; the offline ISO installs with no network. The netinstall builds, at 1.47 GB, so the first acceptance stands open and honest. Both carry the sources they were built from and build a first system from them with no network. The offline one carried nothing else until the flake started evaluating every image: it was the netinstall under a second name, because every setting it made the installation profile had already made. It now carries the reference workstation’s closure, which is the difference between building a graphical system on a laptop and copying one. That is evaluated, not built — the medium is gibibytes of squashfs — and the acceptance stays open until an offline install runs from a written one.

  • Installer in genesis mode with the flow in §5.4; disk plans previewed and laid out by the stem from a typed plan; LUKS2 with TPM2 + PIN; Btrfs layout; impermanence; owner key generation; hardware profile with the NVIDIA choice. The plan, the layout, the sealed key, the genome and the installed system are done, and set out in a test where a machine boots from the disk on the PIN its TPM asks for, refuses a wrong one, and opens the root with the passphrase when booted with a TPM that never sealed anything (ADR-0039). That test has now installed a machine and booted it twice on its PIN; the boot with a chip that never sealed anything is the one part of it still to be shown. A person who brings no key is given one, its secret half written into their home by the stem, and the recovery key is enrolled, handed back once and kept nowhere (ADR-0041); the test boots the machine with the recovery key once its TPM is gone.

  • Lanzaboote integrated; tacid secureboot; the gate proven in a VM (ADR-0080). tacid.core.boot.secureBoot.enable turns on a signed unified image per generation, with boot counting carried across from the same option so the immune system survives the change of installer. tacid secureboot says which of four states a machine is in and what the next step is; keys and enrol move it. Enrolment is the one act here a later generation cannot take back, so it has four gates that do not trust each other: the keeper refuses a principal who could not approve a change, a machine with no keys, and — the one that prevents a brick — a machine that does not sign what it boots; the stem refuses again without a person at the machine; sbctl refuses again unless the firmware is in setup mode.

    *`checks.<system>.secureboot` proves the gate on two machines differing in one option: the
    unsigned one is refused before the stem hears of it, and the signing one gets past every
    check Tacid makes and is stopped by the firmware.*
  • The firmware coming to verify, on emulated hardware: OVMF in setup mode, an EFI partition signed before the image was written, and a reboot that keeps the firmware’s variables. Not built. nix/tests/install.nix writes and boots a real disk and nix/tests/secureboot.nix exercises the gate; joining the two is the step. Until it is taken, nothing here has watched a firmware refuse an unsigned kernel, and both files say so rather than implying more than they show. Certification against real firmware waits on somebody with a machine.

  • Seal the journal to a TPM NV counter (ADR-0060). A counter and not a stored head: an NV value is root’s to overwrite, while a counter can only be incremented and a TPM sets a newly defined one above every value any counter on it has ever held, so undefining the index does not start it over. The keeper moves it on at every conclusion and at every start and records where it stands, so a journal below the counter has had records taken off its end — the one case a chain cannot see by itself. The installation test truncates a journal, watches the chain still verify, and watches tacid-keeper verify exit non-zero. What no machine can stop from inside itself is a root that rewrites the journal and moves the seal on, and the answer to that is a fact kept where the machine cannot reach it: tacid-keeper witness prints one line an owner takes away and checks later, and the installation test proves it catches what the counter cannot (ADR-0072). What is still missing is anything that notices on its own — a witness held and never checked catches nothing, and only a second machine could remind an owner, which is the fleet transport and not this.

  • Channels: manifests with TUF roles; Attic cache; CI promotion to edge. The maintainer’s side exists — tacid-channel makes the keys, the keyring, the signed manifest with every input locked, and signs the cache the way Nix reads it — and the follower’s side is written and set out in a test under software emulation: a machine with nothing in its store but its own closure follows a signed revision from a cache and reboots into it (ADR-0038); the test has been run and is not yet green — every run so far has died on the package build or on the harness rather than on the machine’s behaviour. The cache behind a CDN and the pipeline that promotes are not built yet.

  • tacid-update: quiet-point scheduling, activation classes, boot counting, genome lock commits, retention and garbage collection. Accept: an update that breaks tacidd is rolled back automatically and journaled. Done: the check proposes the advance to the daemon as the system principal, the lock moves and is committed, a channel revision is boot-class, boot counting abandons a revision whose daemon cannot start after three boots, and the keeper says the machine came back and moves the profile to what runs — asserted by the same test. Retention is the owner’s declaration, applied by the keeper and carried out by the stem by number, with tacid-collect on the owner’s timer (ADR-0040); the lifecycle test runs it on a machine with nothing to forget, and the forgetting of real generations is set out for the channel test. And tacid update runs the check now rather than at the hour, through the keeper and the stem, with the verification staying in the core binary (ADR-0077); the update leg exercises the verb for one revision and the unit directly for the other, so both halves are proven.

  • First boot experience. The headless form is built: the installer declares the body’s birth, the keeper reads it back at its first start and writes the line down, and the machine introduces itself once on the console (ADR-0042); the installation test is set out to show it. The graphical moment waits for the shell.

  • Idle RAM and boot budgets measured in CI. Half of it is now measured, and not in CI: the lifecycle leg sums MemoryCurrent over the four units of the nervous system and asserts it against the figure §7 publishes, so the budget is a test rather than a paragraph. The boot budget is not measured, and doing it under software emulation would measure the emulator. Neither runs in CI, for the reason below.

Exit: a fresh install self-updates from edge; an injected bad generation rolls back on its own; the maintainer daily-drives the TUI on a real laptop. The first two hold under software emulation, against a channel the test publishes; the third needs the laptop.

Phase 4 — Face (8–12 weeks)

Goal: a graphical Tacid that is daily-drivable and quietly beautiful.

  • The session with the design system applied; xwayland-satellite; greetd in front of it. Declared by tacid.desktop.enable: the compositor’s colours come from the tokens, its bindings and the shell’s start are composed into /etc/hypr/hyprland.conf, and the workstation configuration evaluates with the session in it. Explicit sync on NVIDIA waits for the card.

  • One compositor, and it is Hyprland (ADR-0053). Hyprland, and no option: a machine whose window manager is a setting is a machine with two faces to design, two to test and two to answer questions about. The option that was here first was a way of not deciding, and the argument that had made Niri the default — that Hyprland’s config churn would break a rollback — was false: a generation pins the compositor and its configuration in one closure, so they always match. Where the compositor lacks something the module supplies it (grim for Print, swaybg for the wallpaper). checks.*.session hands the generated file to Hyprland’s own parser on every push, which is the only thing that can tell whether it means anything — and it failed on its first run, on three layerrule lines an older Hyprland accepted.

  • tacid-shell: bar with badges; the command surface, which opens a program, changes the machine in the terminal’s own words when what was typed begins :do, or says something to the agent — and takes the owner’s word on what it proposes (ADR-0047). Software-rendered, its own workspace, a client of the same protocol the terminal uses. It has been started against a compositor and photographed drawing: checks.*.face runs it under wlroots with the Pixman renderer on every push, measures that the surface is not one flat colour, and reads the words off it — the same binary, under a compositor it was never told about; desktop.nix starts it from the session a machine boots into. What is untested is hardware — a real card, a real screen, and the graphics matrix below.

  • The agent’s own panel with live tool state (ADR-0047). tacid-shell panel, on Mod+A: a renderer of the event stream (ADR-0052) and nothing else, so it and tacid watch agree by construction. A change waiting for the owner’s word can be answered on it, by the same call the terminal makes to the keeper.

  • The bar says when the machine is waiting for an answer. A count from the keeper, in amber, beside the clock (ADR-0047). A change waiting for a word is a machine that has halted, and before this the only way to find out was to open the panel or run tacid pending — which is to say, to already suspect. A count and not a summary: what is waiting is the panel’s to show.

  • What a machine can be asked for, which is what a settings surface was for. A settings application stays refused, for the reason it was always refused: it would be a second place this machine is configured, behind the genome’s back. But the need it served was real and unmet — there was no way to find out what could be asked for. The curated allow-list lived in one Nix module and nowhere else, so a person had to read the source of the distribution and the agent had no list at all: it composed a ServiceEnable with a name it had guessed and learnt from an evaluation error, after the change had been rendered, staged and put to Nix.

    *So the allow-list is data now. One attribute set declares each service once and three
    readers derive from it: the options the genome writes into, a catalogue the system carries
    at `/etc/tacid-system/services.json` beside its declaration, and whoever is reading the
    file. `tacid settings` shows what this machine has been asked to be and what it could be
    asked for, and writes nothing. The same list narrows the proposal schema, so a name that
    cannot be built is one the model is never offered — and, for a local engine held to a
    grammar compiled from those schemas, one it cannot emit
    ([ADR-0056](/decisions/0056-a-local-model-is-held-to-a-grammar-compiled-from-the-tools-it-was-offered)).
    What is left is the same view in the shell, which is a renderer's job.*
  • Notifications as a surface; recovery labels in the boot menu. A notification surface of Tacid’s own is refused on narrower grounds — what Tacid itself has to say fits in a word on a strip that is already on the screen, and a second always-mapped surface, empty most of the time, is a rectangle swallowing clicks for the minority of seconds it has anything in it. Recovery labels remain open.

    *Other programs' notifications, over the freedesktop protocol, were the case that would
    justify one, and they are answered by declaring a daemon rather than by writing a surface:
    the session runs `mako`, configured from the same tokens as everything else. Until it did,
    **nothing on the machine claimed `org.freedesktop.Notifications` at all** — so every
    `notify-send`, from a script, a browser or Tacid's own bar, failed with no error anywhere,
    because a D-Bus call to a name nobody owns is not an error the caller sees.*
    
    *What the machine itself has to say is built, on the strip and everywhere else. Autonomy
    level 2 — the default — promises "acts with notification", and only the acting half had ever
    been implemented: a change a person asks for is reported in the answer to what they asked,
    and a channel advance applied on a timer at four in the morning was reported nowhere. The
    keeper now holds every change it allowed without asking until a call says somebody has been
    shown it, and that call is a line in the journal with a name on it rather than a silence
    ([ADR-0057](/decisions/0057-a-change-nobody-was-asked-about-is-kept-until-somebody-has-been-told)).
    The bar counts them, the panel writes them out, `tacid status` prints them, the interface
    says them as it opens, and reading them is what clears them.*
  • The shell leaves when its surface is closed. The first boot of the session found the bar still running five seconds after the compositor closed its layer surface, invisible and silent. Both surfaces now leave, and say so (ADR-0047).

  • [~] Tokens across apps: terminal, GTK and Qt palettes, editor themes. Generation is done; consumption is the open half. tacid-design now generates every one of these from the one token set — the terminal palettes and the CSS variables, the compositor’s colours, GTK’s settings.ini and gtk.css, and the editor’s theme (Helix, the one a workstation ships). GTK and Qt are also consumed: the module writes GTK’s files under /etc/xdg, which is on XDG_CONFIG_DIRS, and points Qt’s gnome platform theme at the same GSettings. What is generated but not yet consumed is the same set the Ghostty item below names: the terminal and the editor read their configuration only from a person’s own ~/.config, and a machine that will not write there needs a mechanism to offer them a default it does not impose. So Helix is now at parity with Ghostty — its colours exist, from the tokens, in the themes derivation — and the wiring that makes a $HOME-only reader pick them up by default is one decision, made once, for both.

  • Ghostty integration with OSC 133 sessions and “send to Tacid”. Only the colours: tacid-design generates a Ghostty theme, and the module makes Ghostty the default terminal. The integration is not built — nothing reads OSC 133 marks, and there is no “send to Tacid”.

  • [~] Joining a network, from Tacid’s own surfaces. §3.19 says “the agent and the shell are the network UI”. Half of it is built. What exists: tacid network says what the machine is on, and says two things about each interface rather than one — the kernel calls a link up the moment it has a carrier, and only networkd calls it routable, so a laptop associated to an access point that never answered DHCP used to read as a working network. tacid network join <ssid> reads the passphrase from standard input and forget undoes it; both go to the keeper directly, like consent and like the airgap, because the process the secrets are kept from must not be the one they pass through. The keeper registers the value with its scrubber before anything is written down, and journals the network’s name and who asked. What is written is iwd’s own provisioning file, by the stem, since /var/lib/iwd is root’s — writing it is the join, because iwd reads that directory and connects when the network is in range. The SSID is validated at the stem as well as at the keeper: it names a file, so ../../etc/shadow as a network name would otherwise be a root-owned write anywhere on the disk. And scanning, which used to be the paragraph that said what did not. What is in range has no file anywhere, so tacid-facts asks the kernel on a socket rather than reading one: NL80211_CMD_GET_SCAN over generic netlink, which is the cached list each radio last heard and is unprivileged to read — a fresh sweep is CAP_NET_ADMIN, and a machine that triggered one every time somebody asked what was around would be a machine that jammed its own radio. The fact layer’s fake root is honoured: it asks the kernel only when the root is the real one, so every other test still runs against files. mac80211_hwsim plus hostapd is the leg — three networks that exist only in the kernel, one open, one WPA2, one WPA3 — and it watches the words come back and a passphrase reach iwd’s own provisioning file. And the other half of scanning, which is asking for one: tacid network scan [interface] goes keeper-then-stem, and the stem asks iwd over its own D-Bus interface rather than the kernel over netlink, because iwd owns the interface and serialises the scans it starts itself — two requests on one radio is -EBUSY for whoever asked second, which can be this machine’s own supplicant while somebody waits for a list. busctl reaches that interface, so the one process here that runs as root gains no dependency for it, and the object path is derived from two files the kernel writes rather than supplied by a caller. It is asked rather than waited on: iwd answers when a scan has started. What the leg holds is the interface resolution on a machine with four wireless interfaces, the refusals and the authority chain; the busctl call is described by the stand-in stem rather than made, so a real radio is still owed.

  • Graphics matrix: Intel, AMD, NVIDIA desktop, two hybrid laptops; suspend/resume; external displays; XWayland apps.

  • A session a person can live in: volume, microphone, brightness and media keys; a lock screen and an idle watcher that locks before sleep as well as on a timer; region and full-screen screenshots and recordings; a file manager, a colour picker; window rules that float and centre the dialogs and permission prompts a tiling compositor would otherwise bury in a corner. And the window management a person arrives with the habits of: between windows on Alt+Tab rather than only between neighbours, maximise as distinct from fullscreen, a scratchpad that lies over a workspace rather than replacing it, a floating window pinned across all of them, which way the next one splits, and a second screen that windows can come back from. Leaving the session moved off Super+Shift+E to Hyprland’s own Super+Shift+M: a key that ends a session one shift away from the key that opens a person’s files is one that will end it by accident. Every binding comes from one table, and both the compositor’s configuration and the sheet Super+/ draws are generated from it — a key that works and a sheet that does not mention it are two answers to one question, and checks.*.desktop holds the two lists against each other as an equality so neither direction of the drift can happen.

  • The bar says whether the machine is about to stop. system.power carries what each cell holds and the unit it counts in, so two batteries of different sizes are added up by their energy rather than averaged by their percentages. The colour carries the urgency and the words do not move; a crossing — once, downwards, on battery — is said out loud to somebody who is not looking at the strip.

  • The daemon cannot open a socket to the network at all. What four documents described as nftables rules pinning the agent uids to the proxy is RestrictAddressFamilies = [ "AF_UNIX" ], and it is stronger than those rules would have been: a seccomp filter on socket(2) leaves no socket to filter packets on and no local listener to reach as a relay. tacid-egress is the only crate in this tree that opens a TCP connection; the sessions and scribes were already AF_UNIX-only, and tacidd — the least trusted process here — was the one exception. The stem’s nftables.profile operation went with it: the only operation in its surface that read its instructions from a file (ADR-0084). The lifecycle leg holds every unit to it, asserts the proxy is the exception, and proves the filter bites on this kernel rather than trusting that systemd installed it.

  • A key that moves a level says where it got to, and a link opens something. A volume key with no feedback is a key a person presses and then waits to find out about: on a silent desktop, a key that worked and a key bound to nothing are the same experience, which is how nine media keys came to be bound to names no keysym has without anybody noticing. One notification per kind, replacing itself through libnotify’s --replace-id rather than stacking fifteen deep under a held key, and nothing said at all on a machine with no backlight. And xdg.mime.defaultApplications with xdg-open beside it, because a machine that installs a browser and does not declare that it is the browser is one where clicking a link either fails or picks whichever .desktop file it met first.

  • The machine puts itself away. Everything about being left alone was built except the part that saves the machine: a laptop locked, blanked its screen, and stayed awake until the battery was flat. tacid-sleep reads /sys/class/power_supply at the moment it matters — a machine on a cable may be one somebody is watching or one the agent is building on, and a machine with no battery is a desktop that must not sleep while its owner reads the screen beside it. And security.polkit.enable, which nothing had asked for: systemctl suspend is a logind call behind a polkit action, so without it the owner of a laptop could close its lid and could not put it to sleep from their own desktop. That is the case ADR-0005 names as polkit’s, and it needs no agent — logind answers yes for an active seat session.

  • A microphone that a key holds open. Capture and Playback had one implementation each and both were inside #[cfg(test)]: the pipeline was proven, its bounds were tested, its budgets were counted, and no byte of audio had ever passed through them. ALSA rather than PipeWire, because PipeWire publishes an ALSA device and one implementation then reaches a desktop session and a headless machine alike. The device is the person’s executor’s, never the daemon’s — PrivateDevices with char-alsa put back, and the reader’s rather than the scribe’s, because a process with a writable home listening to a room is the one shape this must not have. Super + ` opens it on the way down and closes it on the way up, which is what Hyprland’s release flag is for, and tacid-voice still ends a turn at thirty seconds for the key that sticks. checks.*.voice plays a tone into one end of an snd-aloop card and measures it coming out of the other, with silence as the control. What a turn ends in on a machine built from this tree is a loudness and not words, and for one reason now rather than three. whisper.cpp binding a port rather than a socket was the first, and the supervisor holds a door in front of it (ADR-0086). The seam was the second, and it is written (ADR-0091): a trait, a client in the executor, a field on the wire, and a Transcript that distinguishes words from nothing was asked. What is left is a weights file with a verified hash.

  • The bar says where the person is. A tiling session with ten workspaces and no title bars leaves two facts a person needs every minute with nowhere on the screen to live: which workspace is current, and what has the keyboard. Both are read from the compositor’s own IPC — the event socket as a doorbell that rings “ask me again”, the request socket for every answer — so no second vocabulary has to be kept in step with a compositor that renames its events between releases, and an event nobody has heard of is still a reason to ask. One question at a time: a drag rings the bell dozens of times a second and the last ring is remembered rather than stacked. checks.*.desktop holds the compositor to the field names the bar reads, which is the failure that would otherwise be silent — a rename makes a bar with no workspaces on it look exactly like a machine with none open.

Exit: the maintainer daily-drives the shell; the screenshots are worth posting; the graphics matrix is green on the reference machines.

Phase 5 — Hands (8–12 weeks)

Goal: the workflows that make Tacid a builder’s machine.

  • Projects: detection, dev-shell generation, direnv, the never-rewrite rule, .tacid/project.toml. Detection, the never-rewrite rule and the declaration are built; the shell is generated and shown (ADR-0046), and saving it is the scribe’s second errand: a FileWrite action, classified and snapshotted like any other, carried out by the person’s own process (ADR-0055). The content travels in the item, so the preview a person approves is the file. What is left here is direnv and .tacid/project.toml.
  • Git tools with destructive classification; editor adapters for Neovim and Helix. Reading: status, diff, log, show and branches as the person, through their executor, which writes nothing (ADR-0044). Writing: the scribe, a second executor per person with a writable home, one method and one caller — the keeper, never the daemon, which is not in the group that may open its socket (ADR-0055). A commit is reversible and snapshotted first; throwing away what is not committed is irreversible and asks. Reaching for this found the larger hole it was hiding: nothing had ever carried out an action — the ephemeral half of the envelope ADR-0006 describes — so keeper.act and propose_action were what git writes actually needed, and every other action item now walks into a refusal that names itself rather than into silence. Push is not here and the reason is structural: the scribe has no network, and a push through the egress proxy is its own piece of work. Editor adapters are unwritten. Writing a file that is not a commit is built beside them and shares every step of the road.
  • Containers: Podman, devcontainers, distrobox; the app-source ladder in the command surface. The bottom of it is built: Podman and Flatpak are curated services the agent may ask for, each declaring what it keeps across a reboot. Devcontainers, distrobox and the ladder are not — a person choosing between nixpkgs, a Tacid module, an upstream flake, a Flatpak and a container is choosing on their own.
  • Device center and the hardware graph; udev profiles; tacid device. The graph is a fact, with a role and the granting profile for every probe the curated rules know, and tacid device shows it (ADR-0045); the notification on connection waits for the shell.
  • Probes: ST-Link, J-Link, CMSIS-DAP, Pi Debug Probe, Black Magic; OpenOCD and probe-rs; tacid flash|serial|probe; UART capture and crash summaries. Recognition is built and nothing else is. A probe plugged into a Tacid machine is a fact with a role — ST-Link, CMSIS-DAP and the rest are matched off vendor and product identifiers in system.usb, and tacid device shows the graph (ADR-0045). Nothing flashes anything: there is no OpenOCD, no probe-rs, no tacid flash, no serial capture. The device center knows what is on the desk and cannot use it.
  • Datasheets in sandboxes with citations; browser tool sandboxed with untrusted content. The sandbox is built and is the careful part: a file is identified by its first bytes and never by its name, and the parser for it runs under bubblewrap with no network, no environment, no /proc, one file bound read-only and a timeout (tacid-session). Retrieval and citation are not built, and neither is the browser tool: nothing goes and finds a datasheet, so what exists is the safe way to open one somebody already has.
  • Embedded bench in CI: USB relay, one board per ecosystem, flash and UART verification.

Exit: plug in an ST-Link and an STM32 board, say “build this, flash it and show me the boot log”, and it happens; an embedded engineer flashes a board within ten minutes of install.

Phase 6 — Evolution (6–10 weeks)

Goal: the system improves itself and the species learns.

  • Proposals queue: the agent drafts, attaches CI and evaluation results, the owner signs and pushes; tacid proposals. Built, under tacid propose rather than tacid proposals: the agent writes with source.write, which refuses any path on the core side of the partition, files with source.propose, and a person reads with list and show and signs with sign. Nothing here pushes and nothing here signs — signing takes a closure, so the owner’s key never comes near this code. What is missing is the attaching: a proposal carries a Checks field and nothing fills it in, because there is no continuous integration to fill it from.

  • Core signing partition enforced by the keeper; the self-built marker in tacid version and the bar. The partition is built and enforced — but by the daemon’s own tool, at the moment of writing, and not by the keeper. That is the weaker place for it: it stops the agent writing, and it is not what would stop a machine activating a core the channel did not sign. The marker is rendered in the version output, the terminal and the bar, and is false on every real machine, for the reason under Self-build in Phase 1. Signing itself is not built.

  • tune/: the sanitizer with a published specification, the shown-before-upload bundle, the dataset license, a first fine-tuned Tacid tool-use model in the registry. Three of the four are built (ADR-0074): the sanitizer, its specification in docs/sanitizer.md whose examples a test executes, the bundle behind tacid trace show/export, and the licence in ADR-0017. Collection is tacid.policy.keepTraces, off, and a turn is kept only if there is a store, the policy says yes, and the turn touched nothing untrusted — the third not being the owner’s to waive.

    *And `tune/` exists now, with the half of the pipeline where the questions are: `corpus.py`
    turns bundles into a corpus, and **refuses** what one must not carry. It checks the sanitizer's
    work again from scratch, written from the specification rather than from the sanitizer —
    a second copy of those regexes would agree with the first about everything including its
    mistakes — and it drops rather than repairs, because the second sanitizer is always the one
    nobody reviews. A turn that arrives still carrying identity is a broken export upstream, so it
    is an exit code and a sentence rather than a count. The split is by a hash of the turn itself,
    so a turn can never land in both halves and re-running gives the same ones; two exports of the
    same turn are one example. Eleven tests, standard library only, run by
    `checks.<system>.tune`.*
    
    ***Training is still not built***: *it needs weights, a GPU and a bill, none of which are
    here, and a script that pretended otherwise would be the kind of thing this project refuses.
    Nothing has been fine-tuned and there is no Tacid tool-use model in the registry.*
  • Hardware-quirk loop: tacid propose body turns a local quirk into a proposal for nix/hardware/ (ADR-0073). Built, under tacid propose body rather than tacid body propose: it is a third source of proposals, so it lives beside the other two. nix/ is core and stays core — the module goes in through a second door of fixed shape, Queue::offer_body, which takes no working tree and writes exactly one nix/hardware/*.nix composed by code out of this machine’s firmware identity and the tacid.* lines already in its own body/hardware.nix. Governed by self.modify, the same capability as source.propose. A machine whose firmware will not name it — a virtual machine, or one whose vendor left the field as To Be Filled By O.E.M. — offers nothing, and system.stateVersion never travels. Nothing evaluates the module on a second machine of the same model, because there is no second machine: that is the hardware lab of Phase 7, and until it exists the evidence that a quirk generalises is a person’s.

  • Tacid as an MCP server with per-agent principals and policy (ADR-0061). tacid mcp speaks the Model Context Protocol on standard input and output, which is JSON-RPC over a pipe and therefore a translation of what this machine already speaks over a socket. Before it reads a byte it says integration.hello, and the connection is another agent from then on: capped at consult whatever the owner’s autonomy level says, treated as an agent by the invariants, refused the methods reserved for a person, and named in the record. Nobody has to prove anything to make that call, because the only thing it can do is take authority away. The first surface is six questions — facts, doctor, generations, policy, history, memory — and the lifecycle test holds a conversation with it on a booted machine. Proposing from an integration is not built: it takes a session, and whose memory and whose scribe that session reaches is a question worth answering on its own.

Exit: the agent fixes a real bug in its own tool router, files a proposal, the maintainer signs and merges it, CI publishes to edge, and a second machine receives it.

Phase 7 — Many bodies (ongoing)

Goal: the same nervous system on more bodies.

  • aarch64 images and native builders; a certified aarch64 machine.
  • Headless node profile for boards with a workstation as remote builder. The profile is built — nix/profiles/headless.nix, the same nervous system without a face, and the one profile that turns SSH on because a headless machine is reached over it by definition. It is what the update leg’s follower runs. The remote builder is not built: a board still builds its own generations, which is the half of this that matters for a board that cannot.
  • The hardware lab and the published hardware database.
  • Remote transport for the protocol; one Tacid drives another; team genomes.
  • Apple Silicon community channel where Asahi support is mature.

Exit: parity on a certified ARM machine; one Tacid drives another over SSH with mutual keys.

Phase 8 — 1.0

1.0 means: the update path is trusted; rollback works; the permission model is understandable; transactions are reliable; x86_64 support is broad on documented hardware; NVIDIA is genuinely supported; aarch64 is real on documented devices; the shell is cohesive; developer and embedded workflows are strong; the option surface is frozen; three months have passed without a rollback-worthy regression; and the security architecture has been publicly reviewed.


12. Repository layout and backlog by epic

tacidos/
├── flake.nix  flake.lock
├── nix/            modules/ (core, agent, packages, services, hardware, desktop, dev, user)
│                   profiles/ (workstation, headless, embedded, ai, minimal)
│                   hardware/ (quirks, boards, gpu)  images/  installer/  tests/  overlays/  lib/
├── crates/         built:   tacid-protocol  tacid-stem-protocol  tacid-stem  tacid-policy
│                            tacid-tx  tacid-audit  tacid-keeper  tacid-egress  tacid-model
│                            tacid-facts  tacid-doctor  tacid-memory  tacid-update
│                            tacid-infer  tacid-propose  tacid-install  tacid-session
│                            tacid-daemon  tacid-cli  tacid-design  tacid-voice
│                            tacid-shell (its own workspace: a renderer the daemons never link)
│                   dropped: tacid-tools (the toolbox lives in tacid-daemon; ADR-0031)
├── models/         registry.toml
├── design/         tokens.toml  typography/  icons/
├── docs/           manifesto, architecture, decisions/, rfcs/, guides (mdBook; shipped on-box)
├── hosts/          example genomes for the reference machines
├── tune/           offline fine-tuning pipeline (Python permitted here only)
└── .github/        workflows

Epics (each becomes a tracked milestone): Foundation (flake, CI, protocol, tokens) · Nervous system (stem, keeper, tacidd, egress, infer, session) · Authority (policy, classifier, taint, approvals, secrets, audit) · Body (images, installer, storage, boot, channels, update, immune system) · Mind (providers, routing, local inference, registry, constrained decoding) · Senses (TUI, voice, memory, facts, Doctor) · Face (session, shell, design system, graphics matrix) · Hands (projects, git, editors, containers, device center, probes, datasheets) · Evolution (proposals, core signing, tune, quirk loop, MCP server) · Many bodies (aarch64, lab, remote, fleet) · Project (docs, website, cache, signer, hardware database).


13. Risks

RiskMitigation
Nix complexity leaks into the experienceCurated options, generated-file ownership, actionable evaluation errors, the agent writes the Nix. Two escape hatches, both the owner’s: owner.nix, which Tacid never touches, and a raw-Nix change item, which no agent may compose at any level.
NVIDIA + Secure Boot + frequent kernels is the highest-churn combinationPhysical NVIDIA CI before edge is public; boot-class activation; boot counting; tested driver profiles; stable waits for the matrix.
The compositor has no software renderer, so a machine with no working GPU driver gets no graphical session at all — not a slow one, none (ADR-0053)The recovery path is a tty and the TUI, which is complete by ADR-0020, and the greeter is tuigreet on the console for the same reason. Niri would not have helped: Smithay has none either. A software-rendered fallback session is not written; sway with Pixman is the test’s apparatus, and shipping it would be a second session to keep in step.
Hyprland renames configuration keys between releasesA maintenance cost at upgrade time and not a correctness one: a generation pins the compositor and its generated configuration in one closure, so a rollback can never meet a configuration it does not understand. checks.*.session reads the generated file with Hyprland --verify-config on every push, so a rename is a failed build rather than a greeter somebody cannot get past — evaluation alone would not catch it, because to Nix the file is an opaque string.
The agent is unreliable at system changesTyped tools, constrained decoding, deterministic policy, build before activate, health gate, narrow stem.
A local model disappointsRoute simple work locally, expose frontier providers, fine-tune small on Tacid’s tools, never market every task as local.
The default level 2 is unsafeThe classifier with unknown = irreversible, taint caps, budgets, the immune system in Phase 1, the injection corpus as a gate.
Self-improvement corrupts the coreSigned core partition; keeper verifies before activation; proposals need a human signature.
A leaked key updates every machineTUF roles, short-lived online keys, offline root, revocation by manifest.
Impermanence surprises usersServices declare state; the agent detects ephemeral writes and offers to declare them; the installer explains it once.
Scope explosion (phones, fleets, Macs)The phase gates; Apple as a community channel; boards headless-only; fleets after 1.0.
Solo-maintainer burnoutAgents do the labour; ADRs stop re-litigation; area reviewers by month six; a tight stable.
Trademark collision with existing “TacID” marksClearance before public branding spend.
Binary cache and CI costAttic with deduplication behind a CDN; measured before scaling; sponsors cover bills only.

14. Immediate next actions

Items 4 through 10 of the original list are done and tested; what is left is everything that needs a machine, and it is all the same shape — the code is written and has never met the hardware it was written for.

  1. Reserve the GitHub organization and crate names; start the trademark search.
  2. Buy or designate the reference laptop and the NVIDIA desktop.
  3. Evaluate the Nix layer. Done: every flake output evaluates to a derivation on both architectures, the first evaluation’s eight findings are fixed, and the package builds under Nix with its tests passing inside the sandbox. Boot it. Done, under software emulation: the VM test’s ten subtests pass on a machine that booted — the sockets, the users, the doors the daemon cannot open, a reversible change end to end, and consent brought to the keeper by a person and refused from the agent’s uid. What the first boot found, and what it changed, is ADR-0032.
  4. Replace the two stand-ins on a real machine. Done, under software emulation: the installed machine is told install hello, the daemon locks the genome to the sources the machine carries and builds it, the stem registers the generation and switches to it, hello runs, the genome has its lock and its commit, and tacid undo brings the first generation back with the package gone — with no network, from the assembly tools every closure now keeps. What the first real build found is ADR-0037.
  5. Point a real provider at the proxy. The adapters are tested end to end against a fake provider through the real tacid-egress; what has never happened is a real key, a real response, and a real bill.
  6. Build the netinstall image and install a machine with it. Done, under software emulation: the image builds under its own name (1.47 GB — over the gibibyte, and measured rather than trimmed), and the installation test lays out a disk, writes a genome, installs a built system, boots the disk as the machine and finds it whole — the stem’s guarded operations from a typed plan, with no evaluator on the privileged path (ADR-0033). The netinstall builds the system it installs from the genome, with every input pinned to the sources the image carries, and every machine keeps those sources (ADR-0034), and the test’s live image evaluates and assembles the machine’s first system itself, from the genome and the sources it carries, to the very path the host computes for the same declaration. The machine it installs has an encrypted root whose key the installer sealed to the machine’s TPM behind a PIN, unlocked on the PIN at the console of the initrd and on the passphrase when the TPM cannot open it (ADR-0039), because the default is what is tested; and once booted, it builds its next generation itself (item 4). Still to come: the first netinstall on hardware, with a cache in front of it.
  7. Follow a channel and survive a bad one. Written, and set out in a test under software emulation that has been run and is not yet green — every run so far has died on the build or on the harness rather than on the machine, which is a slower way of learning nothing. What the test sets out: a follower with nothing in its store but its own closure reads a signed manifest through the proxy, the update check proposes the advance as the system, the daemon moves the genome’s lock to the sources the channel locked and builds the revision from the cache, the stem registers it for the next boot, the machine boots it and blesses it; then a revision whose daemon cannot start is tried three times and abandoned by the bootloader (ADR-0038). What the channel needs next is a cache behind a CDN and a pipeline that promotes to it.
  8. Run the fine-tuning pipeline. The corpus builder in tune/ is written and tested; what is left is the training itself, which wants weights, a GPU and sanitised traces worth training on.
  9. Then Phase 4: the session and tacid-shell. It is deliberately last of the written work, because a graphical shell cannot be tested the way everything else here has been, and shipping untested code to keep a phase order would be the first shortcut in the tree.

Appendix A — Example transaction

{
  "schema": 1,
  "id": "tx_01J9Z8X0N4M7Q2R5T8V1W3Y6",
  "kind": "change",
  "intent": "enable_stlink_development",
  "principal": { "kind": "owner", "key": "ed25519:9f3c…" },
  "provenance": ["owner"],
  "items": [
    { "op": "package.ensure", "name": "openocd" },
    { "op": "udev.profile", "profile": "stlink" }
  ],
  "classification": {
    "reversible": true,
    "privilege": "system",
    "persistent": true,
    "network": false,
    "destructive": false,
    "activation": "switch",
    "data_touching": false
  },
  "verification": [
    { "check": "command", "argv": ["openocd", "--version"] },
    { "check": "facts", "path": "system.probes[?vendor=='STMicroelectronics'].accessible", "equals": true }
  ]
}

Appendix B — Example user-visible diff

OpenOCD and ST-Link support                                   tx_01J9Z8X0…  reversible  switch

Tacid will make a persistent system change.
 + install OpenOCD
 + add the tested ST-Link udev profile
 + grant your user access to the debug interface

Why: the connected ST-Link is visible to root but your user cannot open it.

Reversible:  ✓ system configuration (generation 188 → 187)
             ✓ no user files are modified
             ✗ firmware on attached hardware is not affected by this step

[option diff]  [full log]  [approve: y]  [reject: n]

Appendix C — Example mind/agent.nix

{ ... }:
{
  tacid.packages.cli = [ "ripgrep" "fd" "openocd" ];
  tacid.services.openssh = { enable = false; };
  tacid.hardware.udev.profiles = [ "stlink" "cmsis-dap" ];
  tacid.dev.toolchains = [ "arm-none-eabi" "rust-embedded" ];
  tacid.desktop.theme = "phosphor-dark";
}

Appendix D — Example mind/policy.nix

{ ... }:
{
  tacid.policy = {
    autonomy = "reversible";                     # observe | consult | reversible | trusted | sovereign
    sovereign.expiresAfter = "4h";
    capabilities = {
      "packages.install"   = "auto";
      "services.enable"    = "ask";
      "hardware.udev"      = "ask";
      "desktop.theme"      = "auto";
      "shell.exec"         = "sandbox";           # sandbox | ask | deny
      "git.push"           = "ask";
      "firmware.flash"     = "ask";
    };
    filesystem.read  = [ "~/projects" "~/.config" "/etc/tacid" ];
    filesystem.write = [ "~/projects" ];
    egress.allow     = [ "api.anthropic.com" "cache.tacidos.com" "channel.tacidos.com" ];
    egress.dataClasses = { screenshot = "ask"; fileContent = "per-project"; logs = "auto"; secrets = "never"; };
    budgets = { transactionsPerHour = 20; closureDeltaMiB = 4096; egressMiBPerDay = 200; };
  };
}

Appendix E — Example protocol exchange

→ {"jsonrpc":"2.0","id":7,"method":"session.ask","params":{"session":"s_1","text":"install wireshark and let me capture without root"}}
← {"jsonrpc":"2.0","method":"session.event","params":{"session":"s_1","event":"plan","steps":[…]}}
← {"jsonrpc":"2.0","method":"session.event","params":{"session":"s_1","event":"transaction","id":"tx_…","kind":"change","classification":{…},"needs_approval":true}}
   (client connects to keeper.sock)
→ {"jsonrpc":"2.0","id":1,"method":"approval.grant","params":{"transaction":"tx_…","scope":"once"}}
← {"jsonrpc":"2.0","method":"session.event","params":{"session":"s_1","event":"activated","generation":188,"health":"pass"}}
← {"jsonrpc":"2.0","id":7,"result":{"summary":"Wireshark is installed; your user is in the wireshark group; run `wireshark` to capture.","journal":"j_…"}}