Every policy decision in Inhold writes one audit row, allowed or denied. Each row stores the hash of the row before it. People ask me why, and they also ask what it buys them against someone who controls the database. The second question has a less comfortable answer, so I will give both.
How a row is built
An audit row has a fixed set of fields. Inhold takes them as compact JSON with sorted keys and hashes them with SHA-256. One of those fields is prev_hash, the hash of the previous row. The first row points at 64 zeros.
seq, ts, tenant_id, actor_type, actor_id, action, object_id, labels, purpose, decision, reason, policy_hash, prev_hash -> row_hash = sha256(compact sorted JSON of the fields above)
Two more fields, engine_hash and session_id, were added in v0.1 and are hashed when set, so older rows still verify. On SQLite, triggers reject any UPDATE or DELETE on the table. The verify step recomputes every hash from the first row to the last.
What the chain proves
If someone edits one row, its hash changes, and the next row's prev_hash no longer matches. If someone deletes a row in the middle, the same thing happens. Verification fails and names the row.
So the chain proves the rows agree with each other. For an application bug, a bad migration or someone poking at one record, that is useful. It turns a quiet change into a loud one.
It also forced a fix I would not have found otherwise. An early probe ran 200 concurrent writes against the service. 139 of them failed and verification reported a broken chain, because two requests read the same head and both tried to append after it. The fix, written up in ADR-0004, holds a chain lock and commits the audit row in the same transaction as the change it records. On SQLite that is a thread lock plus BEGIN IMMEDIATE; on Postgres it is an advisory transaction lock. A chain that only checks out when traffic is light would have been worth very little.
What the chain does not prove
Here is the part I want reviewers to hear from me first. Someone with full database admin rights can drop the triggers. They can cut rows off the end. They can rewrite every row and recompute every hash from the top. The chain still checks out, because the chain only tests itself.
A hash chain on its own does nothing against the person who owns the database. Saying otherwise would be the easiest false claim to make in this product, and the easiest for a reviewer to break.
Anchors
An anchor is a small record written outside the database: the tenant, a sequence number, the chain head at that number, a timestamp and a signature. The signature is HMAC-SHA256 over the tenant, sequence and head. Inhold writes an anchor when the store is created, every 100 rows by default, and at every evidence export. Each anchor is a separate write-once file, and a second anchor for the same sequence number is refused.
Verification then fails in three cases: an anchor's signature does not match, the chain is shorter than an anchor says it should be, or the row at an anchored sequence has a different hash. It also refuses to pass when there are no anchors at all, since a chain with nothing outside the database to compare against proves nothing about the operator.
This makes a rewrite detectable. It does not make it impossible. The difference matters, and I say "detected, not prevented" in the threat model and on the site.
Where anchors have to live
An anchor only binds the admin if they can neither change it nor forge one. That depends on where it sits and who holds the key.
In v0 on one machine, anchors are write-once files on the same host, signed with a local key when one is configured. The operator of that machine holds both. That shows the mechanism and gives no guarantee. Until the AWS install ships, my advice is simple: keep every evidence pack you receive, and check the next one against it with inhold verify-pack. The pack's own anchors are not trusted, because whoever built the pack wrote them.
The AWS design puts anchors in an S3 bucket with Object Lock in compliance mode, signed with a KMS key of spec HMAC_256. Only a separate anchor role may write to the bucket or call GenerateMac. The gateway itself can verify and never signs. Reviewers get VerifyMac. The code for this is written: an S3 store that refuses overwrites and a KMS signer that produces the same bytes as the local signer. It is tested against a fake KMS. It has not been run against a real AWS account yet, and I will not call it done until it has.
What anchors still miss
Rows written after the newest anchor are not covered. Someone could remove them without a trace. The verification result reports how many rows are unanchored, and lowering the anchor interval shrinks that window.
Anyone holding the service's own database credential is the service. They can append rows that look like real decisions. Anchors catch later rewrites below an anchor. They cannot tell a truthful append from a fake one.
And there is one case where a refusal leaves no row at all. If the database refuses to commit a deny, the call returns an error and the access is still refused, but that refusal is missing from the log. It is on the known gaps list.
Why I still think it is worth it
A reviewer at a hospital is not asking for a log nobody could ever tamper with. They are asking whether tampering would be noticed, and by whom. With anchors held somewhere the database admin cannot reach, the answer is that their own team would notice, from a file they hold. That is a claim they can test, which is the only kind I want to make.