TacidOS
Developers
Documentation

Decision 0069

0069. A promise is kept about the value that was judged

Architecture decision record 0069: 0069. A promise is kept about the value that was judged

Status
accepted
Date
2026-09-07

Status: accepted. Fixes a gap in ADR-0059, which builds the by-identity half of ADR-0010’s fourth invariant.

Context

tacid secret add registers a value the machine will never send. The keeper holds it in its scrubber and publishes the set on a tmpfs the egress proxy reads; the proxy refuses any request carrying one, by identity, naming the registration by fingerprint. SECURITY.md states the boundary plainly: a registered value cannot leave this machine.

Scrubber::register decided whether a value was long enough to be a secret by measuring value.trim().len(), and then stored value.

So the promise was made about one string and kept about another. A value registered as "ghp_…\n" was searched for as "ghp_…\n". scrub left the bare value in a request body untouched, because the body has no newline there. registered_in — the function the proxy refuses on — answered None about a body carrying it. The value the owner said would never leave, leaves.

tacid secret add strips a trailing newline, so the CLI is not the way in. That is the shape of the finding rather than a mitigation of it: a type’s guarantee should not depend on which caller reached it, and the scrubber is registered into from three places — the keeper, the egress proxy reading the published set, and any future one.

Decision

The scrubber trims, and stores what it trimmed. The length check reads the same value it stores. A short value padded out is still too short.

Trimming rather than refusing: a person pasting a token with a newline behind it has not made a mistake worth a second attempt, and the code already believed the trimmed value was the value — that is what it measured. Trimming is safe in the other direction too: a text containing " secret " still has secret removed from between the spaces.

Registered::Added carries the fingerprint of what was stored. The keeper used to compute it from the value as it arrived, which after trimming would name a string the scrubber does not hold — and forget searches by the fingerprint of what is held, so a registration could have become unforgettable. The type that decides what is stored is the type that names it. One thing decides, and it says what it decided.

Consequences

  • A value registered with whitespace around it is now found without it, in scrub and in registered_in alike; a test asserts both against a request body, since that is where the failure would have been.
  • Registered is no longer Copy. Two call sites, both matching on the variant.
  • Published sets written before this are unaffected in practice: the CLI stripped the newline, so what is on disk is already trimmed, and the proxy re-registers through the same function.

The same mistake, five times in one day

This is the fourth finding of one shape and a fifth turned up while this was being written. Naming the shape is worth more than any of them individually. In each, a check was correct and rested on something it did not itself establish:

  • The journal’s id was inside an integrity check that did not reach it, so it could be rewritten to anything and every hash still agreed. verify re-mints it (ADR-0064).
  • The record’s hash was recomputed through the reader’s own types, so the digest described the verifier rather than the disk (ADR-0067).
  • The rollback check compared two revision strings in an order that only holds for a shape only the publisher checked (ADR-0068).
  • And here: the length was measured on the trimmed value and the promise kept about the untrimmed one.
  • The fifth, found by asking the question below of the seal: Entry::Sealed records the journal’s length and the chain’s head at the moment it was written — its own doc comment says the second “is this record’s prev” — and neither was ever read back. verify checks both now. That one is not a tamper fix and says so, and the more useful half of it was amending ADR-0060, whose list of what the counter does not stop was missing the case entirely.

The question that finds them is the same each time, and it is worth asking of any check in this tree: what does this rest on that it does not check itself, and who else has to have been careful for it to be right?

Alternatives rejected

Refuse a value with whitespace around it. More literal, and it puts a person in a loop with a paste that looks identical to the one they just made. It also leaves the asymmetry: the function would still be judging one string.

Trim in every caller. The CLI already does, which is exactly how this survived. Three callers is three chances to forget, and the fourth one has not been written yet.

Store both forms. It doubles the set the proxy scans on every request to protect against a whitespace nobody meant, and it would make fingerprints() report a registration twice.