Verifying a StandardOS audit chain
Every StandardOS export contains an audit_trail array. This document specifies
exactly how each entry is hashed, so that you can recompute the chain yourself,
in any language, without running StandardOS or trusting us.
A complete implementation in about a hundred lines of dependency-free JavaScript is published alongside this document at https://getstandardos.com/verify-chain.mjs. It is written to be read — but if you would rather implement it yourself, this document is enough.
What the chain guarantees
Each entry seals the entry before it. Change any field of any historical entry
and its row_hash no longer matches, and because the next entry's prev_hash
is that hash, every subsequent entry breaks too. Deleting an entry breaks the
link across the gap. Appending a fabricated entry requires knowing the current
head, which is fine — the chain proves that history has not been rewritten,
not that any particular event occurred.
What it does not guarantee on its own
The hash rule is public and unkeyed, which is deliberate: a keyed rule would be verifiable only by whoever holds the key, and that would be us. The consequence is that anyone who can write to the database can also recompute every subsequent hash, and the chain alone will not show it.
That is what the anchors are for. See "Anchors" below. Verify the chain against an anchor receipt you already hold and the recomputation attack is closed, because we cannot alter a receipt that is already in your mailbox.
The preimage
For each entry, the hash is:
row_hash = lowercase_hex( sha256( utf8( preimage ) ) )
The preimage is the concatenation of the following, with no separators except where shown:
| # | Part | Notes |
|---|---|---|
| 1 | prev_hash |
64 lowercase hex characters |
| 2 | old_data |
the string as exported, or "" when null |
| 3 | new_data |
the string as exported, or "" when null |
| 4 | action |
INSERT, UPDATE or DELETE |
| 5 | table_name |
e.g. risks |
| 6 | (hash_version ≥ 2 only) actor + "|" + record_id |
actor is "" when null |
| 7 | (hash_version ≥ 3 only) "|" + principal |
"" when null |
| 8 | occurred_at |
formatted exactly as below |
Each version's preimage is a strict prefix-extension of the previous one, so a
v1 entry is hashed exactly as it was before v2 existed. Apply the rule named
in each row's own hash_version. History is never rehashed when the rule
changes; that is why the column exists.
occurred_at formatting
This is the part people get wrong. The timestamp is rendered in UTC as:
YYYY-MM-DDTHH:MM:SS.ffffffZ
Exactly six fractional-second digits, always present, always zero-padded — for
example 2026-07-30T17:41:39.558458Z. Postgres produces this with
to_char(occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"').
Note that JavaScript's Date.toISOString() emits three fractional digits,
not six, and will therefore produce the wrong hash. The exported occurred_at
is an ISO string with microsecond precision; use its digits directly rather than
round-tripping it through a date type that cannot hold them.
old_data / new_data
Exported as strings, not objects, deliberately. The bytes hashed are
Postgres's canonical jsonb rendering — object keys sorted by length then
bytewise, one space after each : and ,. JSON.stringify does not reproduce
this. Hash the string exactly as exported. Parse it only if you want to read it.
An INSERT has no old_data; a DELETE has no new_data. Both are null in
the export and contribute the empty string to the preimage.
Checking the chain
- Sort entries by
idascending, within oneorg_id. - The first entry's
prev_hashmust equal the genesis seed:sha256(utf8(org_id))as lowercase hex, whereorg_idis the UUID in its canonical dashed lowercase form. - For each entry: recompute
row_hashfrom the preimage and compare; then confirm the next entry'sprev_hashequals this entry'srow_hash.
If step 2 fails, entries have been removed from the beginning. If step 3 fails at entry n, the chain is intact for the first n−1 entries and something at n does not match what was recorded.
Anchors
Each day StandardOS records the head of every organization's chain and emails that organization a receipt containing the head hash, the entry count, and the date. Those receipts are the independent part: they are in your mailbox, not our database.
To use one, take the head hash from a receipt older than the period you care
about, find the entry with that row_hash in your export, and verify the chain
up to that point. A recomputation performed after the receipt was sent cannot
produce that head hash again.
Keep the receipts. They are the reason "even we cannot alter it undetected" is a statement about mathematics rather than about our good intentions.
What an anchor does not cover
An anchor taken at 03:40 says nothing about events written at 09:00. Between two anchor runs there is a window in which an event can be written and removed again with nothing to contradict it: the hash chain does not notice, because deleting the newest entries breaks no link, and no anchor ever covered them.
Two things narrow it rather than close it. Each nightly run checks that the previous anchor still holds — that the event it recorded is still present with the same hash, and that the chain has not shrunk below the count that was anchored — and records an alarm if not. So anything that survives past one anchor is protected from then on. And the deep sweep re-derives every chain from genesis daily, which catches content that was altered without recomputation.
What remains uncovered is one anchoring interval, for events created and destroyed inside it. We would rather write that down than let a reader assume otherwise.
What the hash rule covers, per version
The rule has been extended twice, and each row records which version wrote it.
| Version | Seals |
|---|---|
| 1 | content, action, table, timestamp, and the link to the previous entry |
| 2 | the above, plus actor and record_id |
| 3 | the above, plus principal |
History is never rehashed when the rule changes — rewriting an append-only log
so it verifies under a new rule is exactly the operation the log exists to make
detectable. The consequence is that on a v1 or v2 entry the attribution columns
are recorded but not sealed: they can be altered without breaking
verification. The export marks this per row (principal_attested,
actor_attested), and verify-chain.mjs prints how many entries are affected.
Treat unattested attribution as a claim, not as proof of who acted. The content,
order and timing of every entry are verified regardless of version.
Test vectors
If you have implemented the rule above, check it against these before you trust
your implementation on a real export. Each entry is a complete audit-trail row
and the row_hash it must produce. They cover all three hash versions, both
null and populated old_data/new_data, and a fractional-second value that is
not already six digits.
These are not decoration. Our CI recomputes every one of them on each push using the published verifier, so a change to the hash rule that forgot to update this document fails our build rather than your audit.
[
{
"hash_version": 1,
"prev_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"old_data": null,
"new_data": "{\"id\":1}",
"action": "INSERT",
"table_name": "risks",
"actor": null,
"record_id": null,
"principal": null,
"occurred_at": "2026-07-30T17:41:39.558458Z",
"row_hash": "62586c3fab919870dc2a5eec406fde33ddc31f73b92d5d851acbf3e822b15704"
},
{
"hash_version": 2,
"prev_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"old_data": "{\"id\":1}",
"new_data": "{\"id\":2}",
"action": "UPDATE",
"table_name": "soa_entries",
"actor": "11111111-1111-1111-1111-111111111111",
"record_id": "22222222-2222-2222-2222-222222222222",
"principal": null,
"occurred_at": "2026-07-30T17:41:39.5Z",
"row_hash": "6aad0bb2c1edf6053b297d5a38b88bcc9146fcfb6caa4e0f47dd009bcda60674"
},
{
"hash_version": 3,
"prev_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"old_data": "{\"id\":3}",
"new_data": null,
"action": "DELETE",
"table_name": "documents",
"actor": null,
"record_id": "33333333-3333-3333-3333-333333333333",
"principal": "job:soa-justification-source",
"occurred_at": "2026-08-03T09:00:00Z",
"row_hash": "d895d6280468e1a0600034f15dc3a884a123333a5b73a2df7cf8e470c6cf2fe4"
}
]
Reporting a mismatch
If a verification fails and you believe the data is genuine, tell us at
security@getstandardos.com with the entry id and what you computed. A chain
that fails to verify is either tampering or a bug in our hashing, and we want to
know which as urgently as you do.