TacidOS
Developers
Documentation

Decision 0059

59. A value a person registers is refused by the process the bytes pass through

Architecture decision record 0059: 59. A value a person registers is refused by the process the bytes pass through

Status
accepted
Date
2026-09-06

Status: accepted. Builds the by-identity half of ADR-0010’s fourth invariant, and follows ADR-0058 to the same conclusion by the same road.

Context

Invariant 4 says a secret this machine knows about never enters model context, and names three mechanisms: protected paths, secret:// handles brokered by the keeper, and a scrubber on every tool result, memory write and audit line. The scrubber removes two kinds of thing — values it was told about, and shapes it recognises — and SECURITY.md was honest about the edge between them:

A value with no registered identity and no shape the scrubber knows, sitting in a file the policy allows — a token written by hand into a note in ~/projects, with no prefix and no structure — is caught by none of the three.

And about the door that was not there:

Registering an arbitrary value by identity is not built. The keeper has the method and no verb reaches it… An earlier version of this paragraph offered tacid secrets as the answer, and there is no such command — which is a worse failure than the edge case, because it sends somebody looking for a door that is not there.

Keeper::register_secret existed, took a value, put it in the scrubber, and was called by one test. Nothing a person could type reached it.

Two questions had to be answered before it could be built, and one of them had been answered badly by being left alone.

Where does the value go? The daemon must not hold it: “the daemon holds no secret values” is not a description, it is the reason the daemon can be the least-trusted process in the system. Handing it the values would make the scrubber on its path stronger and the architecture weaker.

What does registering actually buy? The keeper’s own scrubber covers the journal. That is worth having and is not what the edge case was about: the token in the note reaching a provider is the failure, and the keeper never sees those bytes.

Decision

A person registers a value with the keeper, and the egress proxy refuses any request that carries one.

  1. tacid secret add reads the value from standard input. Never an argument: an argument is in /proc/<pid>/cmdline for every process on the machine while the command runs, and in the shell’s history afterwards, which are two of the places a value being registered must not be. The command goes straight to the keeper, like consent and like the airgap, because a declaration about the machine must not travel through the process it constrains.

  2. The keeper is the register, and keeps one copy. The value goes into the keeper’s Scrubber and nowhere else in the process; what the keeper keeps beside it is the fingerprint and the time. The fingerprint — twelve hex characters of SHA-256 — is what a listing shows and what tacid secret forget takes, so an owner can tell two registrations apart and name one without either being displayed. SecretRegisterParams implements Debug by hand, so that a {params:?} in a trace or a panic cannot print the field.

  3. The proxy is told, through a file, the way it is told about the airgap. /run/tacid/secrets/registered, one JSON string per line so a value with a newline in it — a private key — is one line. A tmpfs, so the values never reach a disk. The directory is set-group-id, because a file the keeper’s umask made would otherwise be one the proxy is refused, and that failure looks exactly like a machine that is protecting a value while it is not. The keeper does not create the directory: one it made would carry its own group, so a machine whose module layer declared none gets a refused registration rather than a silent one.

    The group is tacid-secrets, which exists for this and has one member. The first draft used tacid-egress, the proxy’s own group, which was wrong in the way that matters: the daemon is in tacid-egress, because that is how it opens the proxy’s socket. A directory carrying that group would have handed every registered value to the one process this whole design keeps values away from. Caught by writing the test that asserts the daemon cannot read the file and then working out that it could; there are now two assertions, one over the module text and one on a booted machine.

  4. The proxy refuses; it does not redact. A body with a token cut out of it is a request nobody asked for, sent anyway, with an answer nobody can account for. Refusal::Registered names the fingerprint, never the value, and the ledger records the refusal. The check is over the headers as well as the body — a value in a header leaves as surely as one in a body — and the set is read fresh on every request, so a registration made a second ago is in force without restarting anything.

  5. Only the person whose machine it is may register or forget. The gate that lowers the airgap, not the one that raises it. Raising the airgap is a reflex anyone may have and it only ever stops things; this both stops things and starts them. An agent that could register a common word could make every log on the machine unreadable, and one that could forget a registration could take the last thing keeping a token off the wire.

  6. A value too short to be one is refused, not quietly ignored. Under eight characters the scrubber does not take it, because removing it would redact half of everything it appears in. Refusing says so; the old signature returned nothing and a person would have believed it.

  7. Registrations do not survive a reboot, and the listing says what they reach. The values are in tmpfs and in two processes’ memory. Writing them to /persist to save a person one command would be plaintext secrets on a disk, which is the thing this whole area exists to avoid. The durable answer for a file full of them is protected_paths, and tacid secret prints that in its own output rather than letting somebody infer more than it does.

Consequences

  • The edge case named in SECURITY.md is closed for the direction that matters. A token in a note that the agent reads and tries to send is refused at the proxy, by identity, and the refusal is in the ledger.
  • It is not closed for the daemon’s own surfaces. A registered value may still appear in a tool result on the owner’s own screen, and may still reach a memory write, because the daemon holds no values and filters by shape. Closing that means the daemon asking the keeper to scrub text — the daemon still holding nothing, the keeper doing the matching — which is a round trip on the hot path and deserves its own decision rather than being smuggled in here. Until then the boundary is: a registered value cannot leave this machine, and can still be shown on it.
  • The egress proxy now holds one more class of secret. It already holds every provider key this machine has, so the class is not new; what is new is that a value an owner registered to protect is given to the network-facing process in order to protect it. That is the trade, and it is the right way round: the alternative to the proxy knowing the value is the value leaving.
  • A proxy that finds a set it cannot read stops sending, the way one that cannot write its ledger does. Absent means no registrations, which is every machine until somebody registers one; unreadable means the machine cannot honour what it was told, and must not send as though it had been told nothing.
  • Scrubber gains one way out that carries values rather than a redaction of one, for the single caller that has to write them somewhere. It is named for that and documented as not a general accessor.