Decision 0060
60. A chain cannot say how long it should be, so a counter does
Architecture decision record 0060: 60. A chain cannot say how long it should be, so a counter does
Date: 2026-09-06. Points 5, 6 and 9 written 2026-09-07, on the first machine to boot with this.
Status: accepted. Builds the sealing ADR-0010 named and SECURITY.md invariant 8 called unbuilt.
Context
The journal is hash-chained: each record carries the previous record’s hash, and its own hash is
over that plus its contents. Editing a record in the middle is detected. Reordering is detected.
Removing a record from the middle is detected. tacid-keeper verify names the first place the
chain fails.
Dropping records off the end is not detected, and cannot be, by anything inside the file. What
is left after a truncation is a shorter chain in which every link still holds. tacid-audit has a
test called truncating_the_tail_leaves_a_shorter_but_valid_chain, which is the honest name for
it. SECURITY.md said so in the invariant it belongs to:
root can drop the last records and re-chain the rest, and nothing on the machine can tell. The agent cannot: the file is root’s and the keeper’s, and that is the threat this invariant holds against today. Sealing is unbuilt, and named as unbuilt in
PLAN.md.
Nothing inside a chain says how long it should be. Something outside it has to.
Decision
A TPM NV counter is the machine’s seal. The keeper moves it on and writes down where it stands; a journal below the counter has had records taken out of it.
-
A counter, not a stored hash. An NV index that holds a value is root’s to overwrite with whatever it likes, which would make the seal exactly as forgeable as the file. A counter index (
nt=counter) can only be incremented — and the property that matters, the one this whole decision rests on, is that a TPM sets a newly defined counter to the highest value any counter on it has ever reached, plus one. Undefining the index and defining it again does not start the count over. There is no operation, at any privilege on the machine, that lowers it. -
The journal carries the counter, so no base is needed. Each
Entry::Sealedrecords how many records the journal held, the head at that moment, and what the counter stood at afterwards. Checking is then a comparison and not arithmetic against a remembered origin: read the counter, find the lastSealedrecord, and the two either agree or they do not. A machine whose counter is above its journal’s last seal has lost records after that seal. A machine whose counter exists and whose journal records no seal at all has lost every one of them. -
The stem does it. Talking to the TPM is root’s, and the process that keeps the record must not also be the one that could quietly stop sealing it. Two operations —
JournalSealmoves the counter on and reads it,JournalSealReadonly reads — and both are unguarded, because sealing destroys nothing and a record that stops when nobody is at the machine is not a record. The index is the stem’s own constant and never a caller’s: a caller that could name the index could name a different one and start a second, empty count. -
At every conclusion and at every start. Not at every record: a TPM’s non-volatile memory wears, and what an attacker removes is a run of records at the end, which one seal behind them catches as well as six would. The start seal matters on its own — without it a machine that has applied nothing has nothing anchoring its journal, and a truncation across a reboot would leave the counter exactly where the last conclusion left it.
-
And at birth, before anything else is written. One machine has one counter and keeps more than one journal: the live image that installs a machine seals to the same chip the machine will. So the installed machine’s first journal opens under a counter that already stands somewhere, recording no seal of its own — which is, record for record, a journal somebody emptied. A keeper that read that as tampering would accuse every machine it had just installed, and a warning that is wrong every time teaches an owner to skip past the one time it is right. So the first record written into a journal is its seal, before the boot is reconciled and before the machine says who it is. After that, a counter that has moved with no seal recorded against it has one explanation left.
-
Look before sealing, at every start. Sealing writes a record that agrees with the counter it has just moved. A keeper that sealed and then asked what the seal said would find every journal intact — including one somebody had cut the end off a moment earlier, which is the one case any of this exists for. Looking comes first, and a break found is written into the journal as well as said on the way up, because nobody watches most starts and a note taken back out moves the counter again.
-
And bounded, because best-effort was only true of failing. A
tpm2_*that hangs rather than fails is not a failure, and the stem waited on its children for ever. The keeper seals at start, before it reaches its socket, and everything on the machine goes through the keeper — so a chip that answered nothing was a machine that did not finish booting because it could not anchor a journal. Ten seconds per seal command, after which this is a machine that cannot seal, which is a state that was already handled. Only the seal is bounded this way: its output is a few bytes and its duration is known, where an activation’s is neither. -
A machine with no TPM says so.
SealState::Unsealed, intacid-keeper verifyand on the unit’s journal at every start, in a sentence that says what is not being noticed. A board, a virtual machine, a laptop with the chip off in firmware: real and common. Sealing is best-effort and never fatal — a transaction that concluded has concluded — but a machine that could not seal writes noSealedrecord at all, so a missing seal is never read as an intact one. -
tacid-keeper verifyexits non-zero on a broken seal. A chain that verifies and a seal that does not is the case this mechanism exists for, and a journal nobody re-read because the command said nothing was wrong is the failure it is meant to prevent. -
The report goes on the unit’s journal as well as in Tacid’s own. A machine writing “my record has a hole in it” into the record with the hole in it is writing where somebody who edited once can edit again — so the unit’s journal is where a person watching a machine start reads it. But nobody watches most starts, and
verifyand every later reader see only Tacid’s own, so the finding goes in both. -
tpm2-toolsin the system closure.systemd-cryptenrollseals a key to a TPM and has nothing for NV counters; linkingtpm2-tssinto the process that runs as root is a C dependency in the one place this project does not put them. A few megabytes, carried by machines that never use them, which is the trade the rest of the closure already makes.
Consequences
- The threat this closes is a journal edited without the TPM: a disk pulled and mounted
elsewhere, a backup restored, a
/persistrolled back, an image copied. Each of those leaves the counter where it was and the journal behind it. - The threat it does not close is a root that rewrites the journal and moves the seal on. Nothing on a machine can stop a root that drives the machine’s own TPM. What would is an off-machine witness — the owner’s remote, holding the head — and it is not built. This is written here rather than left for somebody to work out, because a seal that is described as making a journal tamper-proof is worse than no seal.
- A machine’s counter is shared across every journal it keeps. A live installer seals to it, then the installed machine seals to it from a fresh journal, and both are consistent because each records what the counter said rather than counting from an origin — and because the fresh one is anchored at birth, which is what keeps the second from reading as the first one robbed.
- A third case, found on 2026-09-07 and missing from this list as first written. Records
removed from before the last seal, with what is left re-chained, leave the last seal in place
and the counter untouched — so the seal still agrees with the chip and the state reads as
intact. The two consequences above name a journal edited without the TPM and a journal erased
to nothing, and a reader would reasonably have taken the middle case to be covered. It is not,
and it never was.
Entry::Sealedrecords the journal’s length and the chain’s head at the moment it was written, and those two claims now have to agree with the record’s own position and predecessor (JournalError::Miscounted,JournalError::Unmoored) — but that is self-consistency and not tamper-proofing: somebody who can write the file can adjust those two fields to match and recompute every hash. It raises the work by one step of the same kind. What closes it is the off-machine witness named above, which is still not built. Written here in the form the second consequence takes, for the same reason: a seal described as making a journal tamper-proof is worse than no seal. - The case the anchor leaves open is a journal erased down to nothing, which is a birth again as far as a counter can tell. It is not a quiet state: the chain head is back at genesis, the machine’s memory of itself is gone, and it introduces itself a second time. But it is not this counter that notices, and the off-machine witness above is what would.
StepgainedCapture, replacing aproducesboolean, because a step’s output is now one of three things and two booleans would have had four states with only three meanings.- The seal is the one operation the stem does not express as a list of steps: it defines the index
only on a machine that has never sealed, which is a branch, and
Vec<Step>has none. It runs through the same step runner, so its failures read like every other operation’s. RecordingStem::count()now means “how many times the machine was asked to change”, excluding seals, because that is the question every one of its callers was asking.