No route edits or deletes an audit row, and that stops the management surface from changing the trail. It does not stop anybody with write access to the D1. The chain is what shows they did. Each tenant’s events are a hash chain, its head is checkpointed outside the database, and an export verifies offline, so an auditor trusts neither your Worker nor us.
Every event is chained to the one before it
Each tenant has its own chain, and the events that belong to no tenant have one too. An event written after the upgrade carries three things beside its fields: its position in the chain, the hash of the event before it, and its own SHA-256 over a canonical serialization of every column.
The personal fields are committed, not included. The hash covers a salted hash of the IP, the user-agent and the metadata, never their values. So an export that leaves the values out still verifies, and the salt is per event, so a commitment cannot be reversed by hashing every IPv4 address.
Concurrent appends never fork a chain. An append reads the head and writes the row in one compare-and-set statement. One that loses the race re-reads the head and tries again, so a burst of writes to one tenant lands every event. A unique index per kind of chain is the backstop under it.
The canonical form is one definition in @pithy-sh/core, which the recorder, the Worker and the CLI all read, so what is written and what is checked cannot drift.
Checkpoints sit outside the database
A chain on its own proves no row changed unless the whole chain was recomputed, and anybody who can write the D1 can recompute it. So every hour a checkpoint Workflow writes each chain’s head and count to an R2 bucket, bound as AUDIT_CHECKPOINTS. One object per checkpoint, keyed by its count, and written only if that key is new, so no run replaces one.
A rewritten chain then disagrees with a checkpoint it cannot reach, and a truncated one ends before a checkpoint says it did.
The Workflow runs in a prebuilt audit host Worker, the shape every kit Workflow takes, which pithy deploy ships beside your Workers. It reads the audit database and writes nothing to it. pithy provision creates the bucket, <project>-<env>-audit-checkpoints.
Verify it in the Worker
GET /audit/control-plane/verify?tenant=… walks one tenant’s chain and answers whether it is whole. tenant is required, because a chain is one tenant’s; send it empty, ?tenant=, for the untenanted chain. since and until bound the window and default to the chain’s start and its head.
| Field | |
|---|---|
intact | Whether every link, every hash and every checkpoint covering the window holds |
checked | How many chained events were checked before the first break, or all of them |
checkpoints | How many checkpoints the walk was held to |
checkpointStore | bound, or absent when the Worker binds no checkpoint bucket. Absent, the walk checked links alone, and a chain rewritten end to end would read as intact |
pruned | How many events the walk crossed as removed by the retention prune, each run bridged by its tombstone. Absent from a Worker that cannot prune |
beforeChain | Events in the window written before the chain began |
outsideChain | Rows with no chain written after it began |
break | The first break, as { eventId, position, reason, detail }, or null |
head | The last position the walk reached intact |
A break names its event and its position, and one of seven reasons:
| Reason | What it means |
|---|---|
edited | The event does not hash to the value it carries |
commitment | A personal value does not match its commitment |
missing | Events before it are gone, with no tombstone to cross. eventId is null, because the trail no longer holds them |
inserted | It is not where the chain says it belongs |
unlinked | It does not name the event before it |
checkpoint | Its hash differs from the checkpoint at its position, or a tombstone disagrees with a checkpoint at either end |
truncated | The chain ends before a checkpoint says it reached |
A run the retention prune removed is crossed, not reported. Before it deletes a row, the prune writes a tombstone to the checkpoint bucket, under the object lock: the run’s positions, the link before it, and the hashes at either end. The walk steps across a pruned run when its tombstone starts where the walk stands, links to the event before it, and agrees with any checkpoint at either end, and the events after it link to the tombstone’s last hash. A gap no tombstone covers is still missing.
The checkpoints are listed before the chain is read, so a checkpoint written while a walk runs is never read as a truncation.
Export it, and verify it without the Worker
GET /audit/control-plane/export?tenant=… hands over the window a page at a time: each event in canonical form with its position and hash, the link before the page as prior, every checkpoint covering it, every tombstone of the chain as tombstones, and complete, which says whether the page runs to the chain’s head. limit is 500 by default and 1000 at most, and nextCursor resumes. A truncation is judged only on a page that runs to the head.
The IP, user-agent and metadata values travel only when the connection also holds audit:events:read_detail. Without it the export carries their commitments, which verify and disclose nothing. A page that carries values says so in detail, and is held to them offline: a value without its salt, or a value missing from a detail export, is a commitment break.
pithy audit verify <file> checks a saved page offline: every hash, every link from prior, every pruned run against its tombstone, every commitment it has values for, and every checkpoint it carries. It reads the file and nothing else, no project, no credentials and no network. Nothing is signed, so nothing needs a key. The checkpoints and tombstones in a file are only as good as where they came from, so fetch them from the bucket yourself when an export’s provenance matters.
Both reads take their own scope
audit:events:export opens the verify and the export, because an export is the trail in bulk, in a form meant to leave the building. It is neither the listing’s scope nor the detail scope, and holding it confers neither. Both routes are GETs, so it is in the default grant. Every call is audited: audit/chain_verified, at critical with outcome failure when it found a break, and audit/chain_exported, with whether the values went out.
Rows the chain does not cover
Events written before the upgrade are marked, never chained. Their chain columns stay null, the listing shows chainSeq: null, and verify counts them as beforeChain. Nothing back-fills them: a chain asserted over rows nobody checked when they were written would be a claim, not evidence.
A row with no chain written after the chain began is counted as outsideChain, not reported as a break. Workers deploy one at a time, an old version and a new one can serve side by side while one rolls out, a rollback brings an old one back, and an older CLI writes into the same D1. An older recorder writing an unchained row is a normal upgrade, not tampering. intact speaks for the chain itself, so such a row never hides a real break after it. A row inserted with a chain position is the walk’s, and is a break.
The erasure attestation cites it
A purge records an audit event per step, on completion, and when it gives up. The erasure attestation points at those events through auditCitations, a seam this capability declares: each by event id, tenant, chain position and hash, never its IP, user-agent or metadata. Hand an auditor the attestation and an export of that chain, and pithy audit verify checks the events it cites. The chain is the attestation’s only integrity mechanism: nothing signs it. Without this capability composed, the attestation says its trail is absent.
What it does not do
It proves the trail is whole. It adds no way to change it. There is still no route that edits, deletes or prunes, and nothing signs an export. The one way an event leaves is the retention prune your own Worker runs, and it leaves a tombstone the verify reads across. The chain shows that rows were changed, removed or inserted; it does not prevent it, and it says nothing about an event that was never emitted.