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.
| Term | Meaning |
|---|---|
| Body | The machine: hardware, firmware, kernel. |
| Nervous system | Tacid: tacid-stem, tacid-keeper, tacid-egress, tacidd, tacid-infer, tacid-session, tacid-shell, tacid. |
| Mind | Whatever model or models the owner chose, local or remote. Pluggable, never assumed. |
| Owner | The human the machine serves, identified by the owner key. Writes the constitution (policy). |
| Principal | Any authenticated actor: owner, local user, the agent, a remote agent, voice (untrusted). |
| Genome | /etc/tacid, a git repository: the complete declaration of this machine. |
| Generation | A built, activatable system closure (a NixOS generation) plus the matching state snapshots. |
| Transaction | A typed request for change. Kind action (ephemeral) or change (persistent, renders Nix). |
| Stem | tacid-stem: root, typed operations only, accepts only the keeper. |
| Keeper | tacid-keeper: policy, reversibility, taint, approvals, secrets, audit, health, snapshots. |
| Egress | tacid-egress: the only path to the network for agent processes. |
| Channel | A signed stream of tested (tacidos, nixpkgs) revisions: stable, edge, dev. |
| Immune system | Health gate + automatic rollback + boot counting, owned by the keeper and the bootloader. |
| Proposal | An agent-drafted change to Tacid’s own source, with CI and evaluation results, awaiting a human signature. |
| Facts | The structured live model of the machine (system.*), derived from kernel and systemd sources, never from parsed shell output. |
| Provenance / taint | Origin labels on every context item; taint propagates and caps autonomy. |
| Session | A 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
- Agency. The agent can inspect, change, diagnose and repair the machine, including its own source, through one typed, previewable, reversible API.
- Sovereignty. Fully functional offline. No accounts, no telemetry, no relay. The owner chooses the mind and holds the keys.
- Self-maintenance. Continuous signed updates with automatic rollback. Install once, never reinstall.
- Beauty. One language, one protocol, one design system, a complete terminal interface and a quiet graphical shell.
- 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
| Process | Runs as | Network | Responsibility |
|---|---|---|---|
tacid-stem | root | none | Socket-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-keeper | tacid-keeper | IPAddressDeny=any | Policy 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. |
tacidd | tacid | via egress only | Sessions 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-infer | tacid-infer, video/render | none | Supervises 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-egress | tacid-egress | loopback in, allowlist out | The 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-session | the person | none | One 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-shell | the 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. |
tacid | the 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_RIGHTSfor audio, pseudo-terminals and large blobs. Notifications carry streams (tokens, tool events, progress). - One crate,
tacid-protocol, defines every message withserde; JSON Schema is published from it and versioned. The stem has a separate, minimal crate. - Authentication:
SO_PEERCREDplus 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 answersyesfor 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
zbusonly 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 inx-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
| Level | Name | Behaviour |
|---|---|---|
| 0 | observe | Read-only. |
| 1 | consult | Proposes everything; asks for everything. |
| 2 | reversible | Default. 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). |
| 3 | trusted | Also acts without asking on owner-declared categories in mind/policy.nix. |
| 4 | sovereign | Everything 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 aresystemd-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’sRestrictAddressFamilies = [ "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 onsocket(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-egressis 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’snftables.profileoperation 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
NETbadge 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 onraises 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 = trueis the declared version: up at every boot, withtacid airgap offlasting 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-serverand 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--hostending 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-serverwithout--jinjanever reads thetoolsfield, 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 notool_callsfield 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. Profilestiny(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 carriestiny; 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 modelsshows 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 onedge, daily onstable; 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 updateruns 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 intacid-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:
edgetracks the newest stable series;stabletracks 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/stableand/edge.
3.16 The immune system
Owned by the keeper and the bootloader, outside the agent’s reach:
- Pre-switch checks: evaluation and build succeeded; closure delta within budget; activation class allowed now.
- Post-switch gate within a window:
boot-complete.targetreached;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. - 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.
- Boot-class failures: systemd-boot counters mark the entry bad after the configured tries and the previous generation boots.
- 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 claimstable. The health gate in that list istacid-doctorovertacid-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 bodycomposes anix/hardware/module out of the machine’s own firmware identity and thetacid.*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.
| Module | Agent | Contents |
|---|---|---|
tacid.core | read | Egress, persistence, retention, the update timer, and the daemons’ own units and sandboxes. |
tacid.owner / tacid.policy | read | Who the owner is and their key; autonomy, capabilities, allowlists, budgets. Owner-signed only. |
tacid.packages | write | Package lists by category: cli, gui, dev, embedded, ai. |
tacid.services | write | Allow-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.hardware | write, approval | Curated udev profiles and the GPU profile. Raw udev rules need review. |
tacid.desktop | write | The session, theme, wallpaper, fonts, keybindings, autostart. |
tacid.dev | write | Toolchains. |
tacid.user | write | Shell, 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 —
wiredon a desktop for a year is furniture — a wireless one draws the network’s name, and one that can reach nothing saysoffline. 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 assending— 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; andSELF-BUILT, because a machine running code it built itself cannot claim a channel’s guarantees. MICis not, and the reason changed rather than went away. A microphone does open now —Super+`holds one, throughtacid 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).
- Three things are drawn only when they say something, which is the same argument each time. A
wired machine draws nothing about its network —
- 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.nixordevenv.nix; optional.tacid/project.tomlfor 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 probeas 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, sotacid.hardware.gpu.profileputs the driver for the card it names there anddoctor bootcatches 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-shelldraws 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.*.facephotographs 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 -bhas every line either way.checks.*.bootrenders 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.
| Surface | Mechanism |
|---|---|
| Daemons | systemd hardening: ProtectSystem=strict, PrivateTmp, NoNewPrivileges, RestrictAddressFamilies, SystemCallFilter, IPAddressDeny where applicable, dedicated users, socket activation. |
| Spawned tools | bubblewrap + Landlock + seccomp; scoped mounts; no network unless the policy grants it, and then only via egress; time and memory limits. |
| Untrusted parsers | PDF, archives, images, web content parsed in sandboxes in the user’s session, never in tacidd. |
| High-risk code | microVM via microvm.nix (Firecracker or cloud-hypervisor) with no devices unless declared. |
| Inference | Subprocesses with read-only model files, seccomp, no network; embedded templates ignored; signed registry. |
| Network | RestrictAddressFamilies = [ "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 chain | Signed 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. |
| Testing | Prompt-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
edgeis public. - Tiers: certified (physically tested by the project), supported (upstream support with active
triage), community (reports exist, no promise). Published at
hardware.tacidos.comor 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 revisionsYYYY.MM.DD-HHMM;tacid versionprints 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/tacidis 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, runsnix 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.nixexposingnixosConfigurationsfor 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) andtune(the corpus builder’s own tests). Notnix 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-protocolv0 with JSON Schema published as a build artifact. Accept: schema round-trips every message in tests. The crate is built and every message derivesschemars::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-designgenerates 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 norunner_idand 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 noCODEOWNERS, 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 --versionprints 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 asfalseon 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-stemwithactivate,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.rsstarts 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 throughtaciddare 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 buildin 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:taciddcannot reach any host except through the proxy; a cloud request succeeds withouttaciddever holding the key. Built, both halves.crates/tacid-model/tests/through_egress.rssends 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.
-
tacidCLI:ask,do,status,diff,apply,undo,history,generations,rollback,version, all with--json. Built,--jsonas 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 ./checkoutfor the agent-mutable crates; the self-built marker. Accept: a patchedtaciddruns 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.writerefusing any path on the core side,source.propose, andtacid propose list/show/sign. The half that builds is not.tacid dev buildis 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 passedfalseby 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 —tacidwith 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: sandboxedllama-serverandwhisper-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, andtacid modelsis 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-voiceholds 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.CaptureandPlaybacknow have implementations that open a device —MicrophoneandSpeakers, over ALSA — andchecks.*.voiceboots a machine withsnd-aloopand 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 isSuperand the backtick in the Hyprland session, abinddown and abindrup, whichchecks.*.desktopasks the running compositor rather than the file.whisper.cppis 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:Transcriberis a trait,TranscriptsaysSaidorUnasked, 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 afileto fetch, so no machine here has spoken to a realwhisper-serverand 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, andtacid memoryover 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.readandservice.status, thelogs.readcapability, 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.tomlholds 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 areproposes,callsandspeaksso 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.enableturns 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 securebootsays which of four states a machine is in and what the next step is;keysandenrolmove 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;sbctlrefuses 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.nixwrites and boots a real disk andnix/tests/secureboot.nixexercises 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 verifyexit 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 witnessprints 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-channelmakes 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 breakstaciddis 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, withtacid-collecton 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. Andtacid updateruns 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
MemoryCurrentover 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 (
grimforPrint,swaybgfor the wallpaper).checks.*.sessionhands 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 threelayerrulelines 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.*.faceruns 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.nixstarts 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, onMod+A: a renderer of the event stream (ADR-0052) and nothing else, so it andtacid watchagree 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
ServiceEnablewith 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-designnow generates every one of these from the one token set — the terminal palettes and the CSS variables, the compositor’s colours, GTK’ssettings.iniandgtk.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 onXDG_CONFIG_DIRS, and points Qt’sgnomeplatform 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-designgenerates 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 networksays what the machine is on, and says two things about each interface rather than one — the kernel calls a linkupthe moment it has a carrier, and only networkd calls itroutable, 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 andforgetundoes 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/iwdis 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/shadowas 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, sotacid-factsasks the kernel on a socket rather than reading one:NL80211_CMD_GET_SCANover generic netlink, which is the cached list each radio last heard and is unprivileged to read — a fresh sweep isCAP_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_hwsimplushostapdis 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-EBUSYfor whoever asked second, which can be this machine’s own supplicant while somebody waits for a list.busctlreaches 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; thebusctlcall 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+Tabrather 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 offSuper+Shift+Eto Hyprland’s ownSuper+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 sheetSuper+/draws are generated from it — a key that works and a sheet that does not mention it are two answers to one question, andchecks.*.desktopholds 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.powercarries 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 onsocket(2)leaves no socket to filter packets on and no local listener to reach as a relay.tacid-egressis the only crate in this tree that opens a TCP connection; the sessions and scribes were already AF_UNIX-only, andtacidd— the least trusted process here — was the one exception. The stem’snftables.profileoperation 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-idrather than stacking fifteen deep under a held key, and nothing said at all on a machine with no backlight. Andxdg.mime.defaultApplicationswithxdg-openbeside 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.desktopfile 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-sleepreads/sys/class/power_supplyat 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. Andsecurity.polkit.enable, which nothing had asked for:systemctl suspendis 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 answersyesfor an active seat session. -
A microphone that a key holds open.
CaptureandPlaybackhad 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 —PrivateDeviceswithchar-alsaput 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, andtacid-voicestill ends a turn at thirty seconds for the key that sticks.checks.*.voiceplays a tone into one end of ansnd-aloopcard 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 aTranscriptthat 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.*.desktopholds 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: aFileWriteaction, 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.actandpropose_actionwere 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, andtacid deviceshows 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 insystem.usb, andtacid deviceshows the graph (ADR-0045). Nothing flashes anything: there is no OpenOCD, no probe-rs, notacid 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, undertacid proposerather thantacid proposals: the agent writes withsource.write, which refuses any path on the core side of the partition, files withsource.propose, and a person reads withlistandshowand signs withsign. 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 aChecksfield 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 versionand 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 isfalseon 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 indocs/sanitizer.mdwhose examples a test executes, the bundle behindtacid trace show/export, and the licence in ADR-0017. Collection istacid.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 bodyturns a local quirk into a proposal fornix/hardware/(ADR-0073). Built, undertacid propose bodyrather thantacid 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 onenix/hardware/*.nixcomposed by code out of this machine’s firmware identity and thetacid.*lines already in its ownbody/hardware.nix. Governed byself.modify, the same capability assource.propose. A machine whose firmware will not name it — a virtual machine, or one whose vendor left the field asTo Be Filled By O.E.M.— offers nothing, andsystem.stateVersionnever 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 mcpspeaks 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 saysintegration.hello, and the connection is another agent from then on: capped atconsultwhatever 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
| Risk | Mitigation |
|---|---|
| Nix complexity leaks into the experience | Curated 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 combination | Physical 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 releases | A 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 changes | Typed tools, constrained decoding, deterministic policy, build before activate, health gate, narrow stem. |
| A local model disappoints | Route simple work locally, expose frontier providers, fine-tune small on Tacid’s tools, never market every task as local. |
| The default level 2 is unsafe | The classifier with unknown = irreversible, taint caps, budgets, the immune system in Phase 1, the injection corpus as a gate. |
| Self-improvement corrupts the core | Signed core partition; keeper verifies before activation; proposals need a human signature. |
| A leaked key updates every machine | TUF roles, short-lived online keys, offline root, revocation by manifest. |
| Impermanence surprises users | Services 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 burnout | Agents do the labour; ADRs stop re-litigation; area reviewers by month six; a tight stable. |
| Trademark collision with existing “TacID” marks | Clearance before public branding spend. |
| Binary cache and CI cost | Attic 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.
- Reserve the GitHub organization and crate names; start the trademark search.
- Buy or designate the reference laptop and the NVIDIA desktop.
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.Replace the two stand-ins on a real machine.Done, under software emulation: the installed machine is toldinstall hello, the daemon locks the genome to the sources the machine carries and builds it, the stem registers the generation and switches to it,helloruns, the genome has its lock and its commit, andtacid undobrings 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.- 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. 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.- 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.
- 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. - 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_…"}}