# Sådan verificerer du en StandardOS-revisionskæde

Enhver StandardOS-eksport indeholder et array `audit_trail`. Dette dokument
fastlægger præcis, hvordan hver post hashes, så du selv kan genberegne kæden,
i et hvilket som helst sprog, uden at køre StandardOS og uden at stole på os.

En komplet implementering på omkring hundrede linjer JavaScript uden
afhængigheder er udgivet sammen med dette dokument på
https://getstandardos.com/verify-chain.mjs. Den er skrevet for at blive læst, men vil du hellere implementere den selv, er dette dokument nok.

## Hvad kæden garanterer

Hver post forsegler posten før den. Ændr et vilkårligt felt i en vilkårlig
historisk post, og dens `row_hash` passer ikke længere, og fordi den næste posts
`prev_hash` er netop den hash, bryder alle efterfølgende poster også. Sletning
af en post bryder leddet hen over hullet. At tilføje en opdigtet post kræver kun kendskab til det aktuelle hoved, og det er i orden. Kæden beviser, at historien ikke er *omskrevet*,
ikke at en bestemt hændelse har fundet sted.

## Hvad den ikke garanterer alene

Hashreglen er offentlig og uden nøgle, og det er bevidst: en regel med nøgle
kunne kun verificeres af den, der har nøglen, og det ville være os. Følgen er,
at enhver, der kan skrive til databasen, også kan genberegne alle efterfølgende
hashes, og kæden alene viser det ikke.

Det er det, **ankrene** er til. Se "Ankre" nedenfor. Verificér kæden mod en
ankerkvittering, du allerede har, og genberegningsangrebet er lukket, fordi vi
ikke kan ændre en kvittering, der allerede ligger i din indbakke.

## Preimage

For hver post er hashen:

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

Preimage er sammenkædningen af følgende dele, **uden skilletegn undtagen hvor
det er vist**:

| # | Del | Noter |
|---|------|-------|
| 1 | `prev_hash` | 64 hexadecimale tegn med små bogstaver |
| 2 | `old_data` | strengen som eksporteret, eller `""` ved null |
| 3 | `new_data` | strengen som eksporteret, eller `""` ved null |
| 4 | `action` | `INSERT`, `UPDATE` eller `DELETE` |
| 5 | `table_name` | f.eks. `risks` |
| 6 | *(kun hash_version ≥ 2)* `actor` + `"\|"` + `record_id` | `actor` er `""` ved null |
| 7 | *(kun hash_version ≥ 3)* `"\|"` + `principal` | `""` ved null |
| 8 | `occurred_at` | formateret præcis som nedenfor |

Hver versions preimage er en streng præfiksudvidelse af den forrige, så en
v1-post hashes præcis, som den blev, før v2 fandtes. **Anvend den regel, som
rækkens egen `hash_version` nævner.** Historien hashes aldrig om, når reglen
ændres; det er derfor, kolonnen findes.

### Formatering af `occurred_at`

Det er den del, folk får galt. Tidsstemplet gengives **i UTC** som:

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

Præcis seks decimaler på sekundet, altid til stede, altid udfyldt med nuller, for eksempel `2026-07-30T17:41:39.558458Z`. Postgres laver det med
`to_char(occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`.

Bemærk, at JavaScripts `Date.toISOString()` giver **tre** decimaler, ikke seks,
og derfor giver den forkerte hash. Det eksporterede `occurred_at` er en
ISO-streng med mikrosekunder; brug dens cifre direkte i stedet for at sende dem
gennem en datotype, der ikke kan rumme dem.

### `old_data` / `new_data`

Eksporteres bevidst som **strenge**, ikke objekter. De bytes, der hashes, er
Postgres' kanoniske `jsonb`-gengivelse: objektnøgler sorteret efter længde og
derefter bytevis, ét mellemrum efter hvert `:` og `,`. `JSON.stringify`
gengiver ikke det. Hash strengen præcis som eksporteret. Parse den kun, hvis du
vil læse den.

Et `INSERT` har ingen `old_data`; et `DELETE` har ingen `new_data`. Begge er
`null` i eksporten og bidrager med den tomme streng til preimage.

## Kontrol af kæden

1. Sortér posterne efter `id` stigende inden for ét `org_id`.
2. Den første posts `prev_hash` skal være lig med **genesis-frøet**:
   `sha256(utf8(org_id))` som hexadecimal med små bogstaver, hvor `org_id` er
   UUID'et i sin kanoniske form med bindestreger og små bogstaver.
3. For hver post: genberegn `row_hash` fra preimage og sammenlign; bekræft
   derefter, at den næste posts `prev_hash` er lig med denne posts `row_hash`.

Fejler trin 2, er der fjernet poster fra begyndelsen. Fejler trin 3 ved post
*n*, er kæden intakt for de første *n−1* poster, og noget ved *n* stemmer ikke
med det, der blev registreret.

## Ankre

Hver dag registrerer StandardOS hovedet af hver organisations kæde og sender
organisationen en kvittering på mail med hovedhashen, antallet af poster og
datoen. De kvitteringer er den uafhængige del: de ligger i din indbakke, ikke i
vores database.

For at bruge en tager du hovedhashen fra en kvittering, der er ældre end den
periode, du interesserer dig for, finder posten med den `row_hash` i din
eksport og verificerer kæden op til det punkt. En genberegning udført efter
kvitteringens afsendelse kan ikke frembringe den hovedhash igen.

Gem kvitteringerne. De er grunden til, at "selv vi kan ikke ændre det uopdaget"
er et udsagn om matematik og ikke om vores gode hensigter.

### Hvad et anker ikke dækker

Et anker taget kl. 03:40 siger intet om hændelser skrevet kl. 09:00. Mellem to
ankerkørsler er der et vindue, hvor en hændelse kan skrives og fjernes igen,
uden at noget modsiger det: hashkæden opdager det ikke, fordi sletning af de
nyeste poster ikke bryder noget led, og intet anker nogensinde dækkede dem.

To ting indsnævrer vinduet uden at lukke det. Hver natlig kørsel kontrollerer,
at det *forrige* anker stadig holder, altså at den registrerede hændelse
stadig findes med samme hash, og at kæden ikke er skrumpet under det forankrede
antal, og registrerer en alarm, hvis ikke. Så alt, hvad der overlever ét anker,
er beskyttet fra da af. Og den dybe gennemgang udleder hver kæde fra genesis
dagligt, hvilket fanger indhold, der er ændret uden genberegning.

Det, der forbliver udækket, er ét ankerinterval, for hændelser skabt og
tilintetgjort inden for det. Vi vil hellere skrive det ned end lade en læser
antage noget andet.

### Hvad hashreglen dækker, pr. version

Reglen er udvidet to gange, og hver række registrerer, hvilken version der
skrev den.

| Version | Forsegler |
|---|---|
| 1 | indhold, handling, tabel, tidsstempel og leddet til den forrige post |
| 2 | ovenstående plus `actor` og `record_id` |
| 3 | ovenstående plus `principal` |

Historien hashes aldrig om, når reglen ændres. At omskrive en log, der kun kan
tilføjes til, så den verificerer under en ny regel, er præcis den operation,
loggen findes for at gøre opdagelig. Følgen er, at på en v1- eller v2-post er
tilskrivningskolonnerne *registreret, men ikke forseglet*: de kan ændres uden
at bryde verifikationen. Eksporten markerer det pr. række
(`principal_attested`, `actor_attested`), og `verify-chain.mjs` udskriver, hvor
mange poster det gælder. Behandl en uattesteret tilskrivning som en påstand,
ikke som bevis for, hvem der handlede. Indhold, rækkefølge og tidspunkt for
hver post verificeres uanset version.

## Testvektorer

Har du implementeret reglen ovenfor, så kontrollér den mod disse, før du stoler
på din implementering på en rigtig eksport. Hver post er en komplet
revisionssporsrække og den `row_hash`, den skal give. De dækker alle tre
hashversioner, både tomme og udfyldte `old_data`/`new_data`, og en
sekundbrøkdel, der ikke allerede har seks cifre.

Det er ikke pynt. Vores CI genberegner hver eneste af dem ved hvert push med
den udgivne verifikator, så en ændring af hashreglen, der glemte at opdatere
dette dokument, får vores build til at fejle i stedet for din revision.

```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"
  }
]
```

## Sådan melder du en uoverensstemmelse

Fejler en verifikation, og mener du, at dataene er ægte, så skriv til os på
security@getstandardos.com med postens `id` og det, du beregnede. En kæde, der
ikke verificerer, er enten manipulation eller en fejl i vores hashing, og vi
vil lige så gerne som du vide hvilken.
