# 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

1. Sort entries by `id` ascending, within one `org_id`.
2. The first entry's `prev_hash` must equal the **genesis seed**:
   `sha256(utf8(org_id))` as lowercase hex, where `org_id` is the UUID in its
   canonical dashed lowercase form.
3. For each entry: recompute `row_hash` from the preimage and compare; then
   confirm the next entry's `prev_hash` equals this entry's `row_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.

```json
[
  {
    "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.
