# Een StandardOS-auditketen verifiëren

Elke StandardOS-export bevat een array `audit_trail`. Dit document legt precies
vast hoe elke regel wordt gehasht, zodat u de keten zelf kunt narekenen, in
elke programmeertaal, zonder StandardOS te draaien en zonder ons te vertrouwen.

Een volledige implementatie in ongeveer honderd regels JavaScript zonder
afhankelijkheden is naast dit document gepubliceerd op
https://getstandardos.com/verify-chain.mjs. Ze is geschreven om gelezen te worden, maar wie het liever zelf implementeert, heeft aan dit document genoeg.

## Wat de keten garandeert

Elke regel verzegelt de regel ervoor. Verander een willekeurig veld van een
willekeurige historische regel en de `row_hash` klopt niet meer, en omdat de
`prev_hash` van de volgende regel die hash is, breekt ook elke volgende regel.
Een regel verwijderen breekt de schakel over het gat. Een verzonnen regel toevoegen vereist alleen kennis van de huidige kop, en dat is prima. De keten bewijst dat de geschiedenis niet is *herschreven*,
niet dat een bepaalde gebeurtenis heeft plaatsgevonden.

## Wat ze op zichzelf niet garandeert

De hashregel is openbaar en zonder sleutel, en dat is opzet: een regel met
sleutel zou alleen te verifiëren zijn door wie de sleutel heeft, en dat zouden
wij zijn. Het gevolg is dat iedereen die naar de database kan schrijven ook elke
volgende hash opnieuw kan berekenen, en de keten alleen laat dat niet zien.

Daar zijn de **ankers** voor. Zie "Ankers" hieronder. Verifieer de keten tegen
een ankerbewijs dat u al in bezit hebt en de herberekeningsaanval is gesloten,
omdat wij een bewijs dat al in uw postvak ligt niet meer kunnen veranderen.

## De preimage

Voor elke regel is de hash:

```
row_hash = lowercase_hex( sha256( utf8( preimage ) ) )
```

De preimage is de aaneenschakeling van de volgende delen, **zonder
scheidingstekens behalve waar aangegeven**:

| # | Deel | Opmerkingen |
|---|------|-------|
| 1 | `prev_hash` | 64 hexadecimale tekens in kleine letters |
| 2 | `old_data` | de tekenreeks zoals geëxporteerd, of `""` bij null |
| 3 | `new_data` | de tekenreeks zoals geëxporteerd, of `""` bij null |
| 4 | `action` | `INSERT`, `UPDATE` of `DELETE` |
| 5 | `table_name` | bijvoorbeeld `risks` |
| 6 | *(alleen hash_version ≥ 2)* `actor` + `"\|"` + `record_id` | `actor` is `""` bij null |
| 7 | *(alleen hash_version ≥ 3)* `"\|"` + `principal` | `""` bij null |
| 8 | `occurred_at` | precies opgemaakt zoals hieronder |

De preimage van elke versie is een strikte prefixuitbreiding van de vorige, dus
een v1-regel wordt precies zo gehasht als voordat v2 bestond. **Pas de regel toe
die de eigen `hash_version` van de rij noemt.** De geschiedenis wordt nooit
opnieuw gehasht als de regel verandert; daarom bestaat de kolom.

### Opmaak van `occurred_at`

Dit is het deel dat mensen fout doen. Het tijdstempel wordt **in UTC**
weergegeven als:

```
YYYY-MM-DDTHH:MM:SS.ffffffZ
```

Precies zes cijfers achter de seconde, altijd aanwezig, altijd met nullen aangevuld, bijvoorbeeld `2026-07-30T17:41:39.558458Z`. Postgres maakt dit met
`to_char(occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`.

Let op: `Date.toISOString()` in JavaScript geeft **drie** cijfers achter de
seconde, geen zes, en levert daarom de verkeerde hash. Het geëxporteerde
`occurred_at` is een ISO-tekenreeks met microseconden; gebruik de cijfers
rechtstreeks in plaats van ze door een datumtype te halen dat ze niet kan
vasthouden.

### `old_data` / `new_data`

Bewust geëxporteerd als **tekenreeksen**, niet als objecten. De gehashte bytes
zijn de canonieke `jsonb`-weergave van Postgres: objectsleutels gesorteerd op
lengte, dan bytegewijs, één spatie na elke `:` en `,`. `JSON.stringify`
reproduceert dat niet. Hash de tekenreeks precies zoals geëxporteerd. Parseer
haar alleen als u haar wilt lezen.

Een `INSERT` heeft geen `old_data`; een `DELETE` heeft geen `new_data`. Beide
zijn `null` in de export en dragen de lege tekenreeks bij aan de preimage.

## De keten controleren

1. Sorteer de regels op `id` oplopend, binnen één `org_id`.
2. De `prev_hash` van de eerste regel moet gelijk zijn aan het **genesis-zaad**:
   `sha256(utf8(org_id))` als hexadecimaal in kleine letters, waarbij `org_id`
   de UUID is in zijn canonieke vorm met streepjes en kleine letters.
3. Voor elke regel: bereken `row_hash` opnieuw uit de preimage en vergelijk;
   bevestig daarna dat de `prev_hash` van de volgende regel gelijk is aan de
   `row_hash` van deze regel.

Als stap 2 faalt, zijn er regels aan het begin verwijderd. Als stap 3 faalt bij
regel *n*, is de keten intact voor de eerste *n−1* regels en komt iets bij *n*
niet overeen met wat is vastgelegd.

## Ankers

Elke dag legt StandardOS de kop van de keten van elke organisatie vast en mailt
die organisatie een bewijs met de kophash, het aantal regels en de datum. Die
bewijzen zijn het onafhankelijke deel: ze liggen in uw postvak, niet in onze
database.

Neem om er een te gebruiken de kophash uit een bewijs dat ouder is dan de
periode waar het u om gaat, zoek de regel met die `row_hash` in uw export en
verifieer de keten tot dat punt. Een herberekening na het versturen van het
bewijs kan die kophash niet opnieuw opleveren.

Bewaar de bewijzen. Zij zijn de reden dat "zelfs wij kunnen het niet
onopgemerkt veranderen" een uitspraak over wiskunde is en niet over onze goede
bedoelingen.

### Wat een anker niet dekt

Een anker om 03:40 zegt niets over gebeurtenissen die om 09:00 zijn geschreven.
Tussen twee ankerruns zit een venster waarin een gebeurtenis geschreven en weer
verwijderd kan worden zonder dat iets het tegenspreekt: de hashketen merkt het
niet, omdat het verwijderen van de nieuwste regels geen schakel breekt, en geen
anker ze ooit heeft gedekt.

Twee dingen maken dat venster kleiner zonder het te sluiten. Elke nachtelijke
run controleert of het *vorige* anker nog geldt, dat wil zeggen dat de
vastgelegde gebeurtenis nog aanwezig is met dezelfde hash en dat de keten niet
onder het verankerde aantal is gekrompen, en legt anders een alarm vast. Alles
wat één anker overleeft, is vanaf dan beschermd. En de diepe controle leidt elke
keten dagelijks opnieuw af vanaf genesis, wat inhoud opspoort die zonder
herberekening is veranderd.

Wat ongedekt blijft, is één ankerinterval, voor gebeurtenissen die daarbinnen
zijn aangemaakt en weer vernietigd. Wij schrijven dat liever op dan een lezer
iets anders te laten aannemen.

### Wat de hashregel dekt, per versie

De regel is twee keer uitgebreid, en elke rij legt vast welke versie haar heeft
geschreven.

| Versie | Verzegelt |
|---|---|
| 1 | inhoud, actie, tabel, tijdstempel en de schakel naar de vorige regel |
| 2 | het bovenstaande, plus `actor` en `record_id` |
| 3 | het bovenstaande, plus `principal` |

De geschiedenis wordt nooit opnieuw gehasht als de regel verandert. Een
alleen-toevoegen-logboek herschrijven zodat het onder een nieuwe regel
verifieert, is precies de handeling die het logboek detecteerbaar moet maken.
Het gevolg is dat op een v1- of v2-regel de toeschrijvingskolommen *vastgelegd
maar niet verzegeld* zijn: ze kunnen worden gewijzigd zonder de verificatie te
breken. De export markeert dat per rij (`principal_attested`,
`actor_attested`), en `verify-chain.mjs` drukt af hoeveel regels het betreft.
Behandel een niet-geattesteerde toeschrijving als een bewering, niet als bewijs
van wie heeft gehandeld. Inhoud, volgorde en tijdstip van elke regel worden
ongeacht de versie geverifieerd.

## Testvectoren

Als u de bovenstaande regel hebt geïmplementeerd, controleer haar dan tegen
deze vectoren voordat u uw implementatie op een echte export vertrouwt. Elke
regel is een volledige auditrij met de `row_hash` die zij moet opleveren. Ze
dekken alle drie de hashversies, zowel lege als gevulde `old_data`/`new_data`,
en een secondefractie die niet al zes cijfers heeft.

Dit is geen versiering. Onze CI berekent elk van deze vectoren bij elke push
opnieuw met de gepubliceerde verifier, zodat een wijziging van de hashregel die
dit document vergeet bij te werken onze build laat falen en niet uw 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"
  }
]
```

## Een afwijking melden

Als een verificatie faalt en u gelooft dat de gegevens echt zijn, mail ons dan
op security@getstandardos.com met de `id` van de regel en wat u hebt berekend.
Een keten die niet verifieert, is ofwel manipulatie ofwel een fout in onze
hashing, en wij willen net zo dringend als u weten welke van de twee.
