What an evidence vault can prove without a notary
What content hashes, separated review and a chained audit log establish inside one application, and where blob deletion and the signing key's holder limit them.

A compliance evidence vault serves people who aren’t supposed to take each other’s word for things. Someone submits a file to show that a control is working, someone else decides whether it does, an auditor arrives later wanting to know what happened, and a retention schedule eventually wants the file gone unless a legal hold says otherwise. Storing the file is the least of it. All of those people need a believable account of what happened to that file and who was responsible. In August 2026 I built one as an ordinary application, with no external notarization service in the design, which makes the useful question how much an application can establish about itself before it has to ask someone else to vouch for it. The answer is a fair amount, as long as each mechanism is only asked to prove what it can.
Start with the bytes. Evidence goes into a blob store under a key made of the tenant and the file’s SHA-256, and the vault’s own record keeps only the digest and a bounded text extract. Content addressing gives you duplicate detection and download verification more or less free, although the duplicate check needs some care. It runs before the blob is written and again inside the state update, since another submitter can win the race between those two moments, and it separates two events that look alike from the outside. A hedged retry of the same intake is recognized by its intake key, while a second submission of the same content gets a 409 naming the existing item. On the way out, the download re-hashes what it read, and a mismatch becomes an integrity failure that names the item and nothing more. So the vault can say these are the bytes that went in. A digest can’t tell you who produced them or whether the document is telling the truth.
The review rule is shorter to state than to keep: the named accountable owner cannot decide on their own evidence. That owner is recorded separately from the caller who uploads the file. I enforced role separation in three places. The route mount turns away callers whose role doesn’t allow the operation. The domain function that works out what is blocking a decision checks ownership before anything else, so no other role the owner holds can get them past it. The loader that feeds the workspace is scoped to the viewer. One test has the rule for a name, the owner can never decide their own evidence, and an end-to-end test has an auditor refused on the action, HTTP and MCP faces alike, because this vault serves the same declared surface to a React workspace, plain HTTP and MCP agents, and a refusal on the page means little if the API will do the job instead.
Those refusals are only as good as the vault’s idea of who is asking, so the actor never comes from input. That is ordinary good practice for a form, and it matters a lot more once one of the callers is a language model, because a tool argument is the last place you want to find the name of a reviewer. Idempotency is scoped to the tenant and the caller’s email, so one person’s retry can’t be answered with another person’s result. Sessions begin with a signed SSO assertion, whose HMAC is checked over the exact body before anything parses it and whose event ID is spent inside the vault’s own compare-and-swap, so a replay has no gap to slip through. The assertion has bounded age and expiry, and issuing the session writes its own audit record.
Recording all of this is where the design made its central decision. The store underneath has no multi-key transactions, so the vault’s records and audit trail are one document, and a compare-and-swap on its version is their transaction. A concurrent writer is detected by the version guard, re-read and re-applied, with up to twelve attempts, and nothing uses a plain put. For most applications this would be a poor choice, since every writer contends for one value and every change rewrites it. Here it buys an important property: a record change and its audit link land in the same write. The metadata cannot commit ahead of its log entry, though operations on the separate blob store are outside that transaction.
Each link in that log carries its fields and the previous link’s MAC, with an HMAC-SHA-256 under a root secret covering all of them. Verification checks that each link points at its predecessor, looks for gaps in the sequence and compares MACs in constant time. The test on it is the best short description of what you get. A link that has been altered, moved to another tenant, dropped or reordered fails verification. A truncated prefix passes, because if you cut the newest links off the end, what remains is a perfectly valid shorter chain. That’s why audit and export responses carry the chain’s length alongside a flag saying whether the full chain verified, and the length only helps someone who remembers the last one they saw.
Retention exposes the consequence of that boundary. Evidence belongs to a retention class with a set number of days, and an admin can apply or release a legal hold, with each action audited. Erasure first refuses an item under hold or still within retention, then deletes the blob before attempting the guarded record update. That update checks the hold and retention again, but the second check comes too late to protect the bytes: a hold applied after the first check can preserve the record after its file has already been deleted. A failure between those steps can likewise leave a record pointing at a missing file without a committed erasure entry. When the record removal succeeds, its audit entry lands in the same write, so the file can be gone while the record of its removal remains. The retention job itself marks eligible evidence stale rather than deleting it, and its dry run counts those candidates. The single-document transaction protects the metadata transition; it does not make blob deletion and a concurrent legal hold atomic.
All of these statements run out at the same place. The chain’s key is the same root secret that keys session MACs. The chain therefore protects against someone who can alter the stored document without holding that secret, and does nothing against someone who does hold it, who can rewrite the whole history and recompute every MAC. That includes whoever operates the application, the very party a skeptical auditor would like to be independent of. The export gives an auditor a manifest, the audit records, the flag and a pointer to the file store, without the bytes. An auditor can’t recompute the MACs without the key and shouldn’t have the key, so the flag is the application’s statement about itself.
So the vault can check that downloaded bytes match the recorded digest, that the reviewer differs from the named accountable owner, and that its record changes commit with their audit links. Those guarantees do not cover an atomic transition across the blob store and the records. Someone relying on those statements is also relying on whoever holds the key, which is the difference between tamper evidence inside an application and notarization, and none of it settles legal admissibility or compliance. Closing the gap takes something outside the application that the operator can’t edit later, such as a chain head published somewhere else, which would expose both truncation and a wholesale rewrite. This vault stops at the application’s edge, so against whoever holds the key its statements are only that party’s word.
Chris Chabot, November 2026