On this page

Validate it · docs/validation/data-integrity.md

Data integrity and audit trail specification

Document id CS-VAL-10
Title Data integrity and audit trail specification
Version 1.0
Applies to Countersign 1.0.0
Status Template supplied with the software; to be completed, reviewed and approved by the regulated user

1. Purpose

This document specifies the audit trail of Countersign 1.0.0 precisely enough for an auditor to verify a trail without the application, states what the integrity mechanisms detect and what they do not, maps the design to the ALCOA+ attributes, and gives the procedures for anchoring and review. Sources: lib/types.ts, lib/engine.ts (commit, stable, hashEntry, replay, verifyChain), lib/sha256.ts, server/service.ts, server/storage/*.ts, db/postgres.sql.

The central design fact: the audit trail is the system of record. Nothing else is stored. The records, their versions and the configuration in effect are computed by replaying the trail. There is no second copy of the data that could disagree with its history.

2. The audit entry

One entry is written for every accepted command, and none for a refused one. Entries are JSON objects.

Field Type Always present Content Set by
seq integer Yes Number of the entry, starting at 1, increasing by 1 Engine
at string Yes Time of the action: ISO 8601, UTC, milliseconds, for example 2026-02-03T07:11:53.793Z Server clock (never the browser; never earlier than the previous entry)
actor string Yes Identifier of the acting person as delivered by the identity provider; system for the first configuration written by the server or the tool Server, from the session
actorName string Yes The person's name at the time Server, from the session
actorRoles array of strings Yes The role ids the person held at the time Server, from the session
action string Yes create, edit, confirm, approve, step-back, retire, remove, import or configure Engine, from the command
recordId string Yes The record acted on; CONFIG for the configuration Engine
recordType string Yes The record's type; config for the configuration Engine
v integer Yes The version produced or changed; for a configuration its revision Engine
before any JSON value Yes (null if nothing) The previous value: see the table below Engine
after any JSON value Yes The new value: see the table below Engine
reason string Only if given The reason or note; text of at most 2000 characters (the engine refuses anything else: "The reason must be text of at most 2000 characters."; the same limit applies to a signature's meaning) The person
command object Yes The command exactly as submitted: its kind and parameters, including the complete new data of a create or edit and the complete document of a configure The person, through the interface or the tool
auth object When the server knew method: entra, oidc, proxy, demo, cli or system. authTime: when the person last authenticated (not with proxy). issuer: the identity provider; for cli the operating-system account and host Server
prevHash string Yes The hash of entry seq - 1; for entry 1 the genesis value, 64 zeros Engine
hash string Yes SHA-256 over all other fields, 64 lowercase hexadecimal characters (section 3) Engine

Content of before and after per action:

Action before after
create null The record's data
edit The data of the version edited (draft) or of the approved version (when a new version is opened) The new data
confirm { "state": "created" } { "state": "confirmed" }
approve { "state": "confirmed" } { "state": "approved", "meaning": "<the signer's statement>" }
step-back { "state": "approved", "approvedBy": "<id>" } or { "state": "confirmed" } { "state": "confirmed" } or { "state": "created" }
retire, remove { "lifecycle": "<previous>" } { "lifecycle": "retired" } or { "lifecycle": "removed" }
import null { "data": …, "origin": { "system", "id", "batch", "approvedBy"?, "approvedAt"? } }
configure null or { "hash": "<SHA-256 of the previous document>" } { "hash": "<SHA-256 of the new document>", "id", "name" }

What an entry does not contain: the IP address or device of the person; a record of what was displayed; anything about refused commands or about reading.

3. How the hash is computed

3.1 Definition

hash = lowercase_hex( SHA-256( UTF-8( prevHash + canonical(entry without "hash") ) ) )
  • prevHash is the 64-character string stored in the entry. It appears twice in the hashed text: once as the prefix and once inside the canonical JSON as the member prevHash.
  • + is string concatenation with nothing in between.
  • For entry 1, prevHash is the genesis value 0000000000000000000000000000000000000000000000000000000000000000.

3.2 Canonical JSON (stable in lib/engine.ts)

Value Canonical form
null, true, false As in JSON
Number As JavaScript's JSON.stringify writes it: integers without a decimal point (5, 30000), other numbers in the shortest form that reads back to the same value (0.1, 12.5)
String As JSON.stringify writes it: in double quotes; " and \ escaped with a backslash; control characters below U+0020 escaped (\n, \r, \t, \b, \f, others as \u00xx in lowercase); every other character, including non-ASCII, written literally
Array [ elements in their order, separated by , ], no whitespace
Object { members sorted by key, each as "key":value, separated by , }, no whitespace. Members whose value is undefined are left out. Keys are sorted as JavaScript sorts strings (by UTF-16 code unit; for ASCII keys this is plain byte order)

The purpose of sorting is that the hash does not depend on the order in which a database returns the members (FR-INT-05). The entry as stored may have its members in any order.

3.3 Worked example

Entry 2 of the fleet demo data, as stored:

{"seq":2,"at":"2026-02-03T07:11:53.793Z","actor":"greta","actorName":"Greta Novak","actorRoles":["regional-lead"],"action":"create","recordId":"RG-0001","recordType":"region","v":1,"before":null,"after":{"name":"North","code":"NO"},"command":{"kind":"create","type":"region","data":{"name":"North","code":"NO"}},"prevHash":"006570ac9d454018effe7aec7ad3fef179154d9d66b6fbfb922e6460eddcbdc6","hash":"a8a7651120e3e7126fb7b8d203e9a35b8e0feff2513357977ed824190587509e"}

The text that is hashed (one line, no line break at the end):

006570ac9d454018effe7aec7ad3fef179154d9d66b6fbfb922e6460eddcbdc6{"action":"create","actor":"greta","actorName":"Greta Novak","actorRoles":["regional-lead"],"after":{"code":"NO","name":"North"},"at":"2026-02-03T07:11:53.793Z","before":null,"command":{"data":{"code":"NO","name":"North"},"kind":"create","type":"region"},"prevHash":"006570ac9d454018effe7aec7ad3fef179154d9d66b6fbfb922e6460eddcbdc6","recordId":"RG-0001","recordType":"region","seq":2,"v":1}

Its SHA-256 is a8a7651120e3e7126fb7b8d203e9a35b8e0feff2513357977ed824190587509e, the entry's hash. You can check this with any SHA-256 tool, for example by saving the text without a trailing newline and running sha256sum.

The configuration's SHA-256 shown by countersign status, countersign config validate and /api/health is SHA-256(UTF-8(canonical(configuration document))) (configHash), with the same canonical form and no prefix.

3.5 Implementation note

The application computes SHA-256 with its own synchronous implementation in lib/sha256.ts, so that the same code runs in the browser and on the server, not with the platform's cryptographic library. The test [FR-INT-01] in lib/engine.test.ts checks that implementation against published SHA-256 known answers and against node:crypto. The standalone script in section 6.2 also uses node:crypto and so checks every hash of a real trail independently.

4. What the software checks

4.1 When an entry is written

Check Where
The command passes every rule of the engine as the acting person at the server's time act
The time is not earlier than the newest entry act, Service.submit
The entry's number is head + 1 and its prevHash is the head's hash Store.append; in PostgreSQL and SQLite also the insert trigger and the primary key
The response to the user is sent only after the store accepted the entry Service.submit

4.2 When entries are read: replay (replay)

Every server process replays every entry it reads from the store, and countersign verify replays the whole trail from entry 1 on an empty state. For each entry, in order:

  1. The entry must be an object with a command; otherwise "the entry is not readable".
  2. The stored command is run again through the engine as the stored actor (actor, actorName, actorRoles), at the stored time at, with the stored auth, on the state built from all earlier entries. If the engine refuses it, the entry "breaks a rule" (for example an approval by the author, an action by someone without the permission, a time before the previous entry).
  3. The entry the engine produces is hashed and compared with the stored hash, and the stored entry's own contents are hashed and compared with its stored hash. A difference is reported as one of: "its contents were changed after it was written (the hash no longer matches)", "the chain is broken: it doesn't point at the entry before it", or "re-running its command gives a different result".

Because the engine computes seq, prevHash, recordId, v, before and after itself during replay, a stored entry cannot claim a result that its command does not produce. This is what catches a forged entry whose hash was recomputed correctly (FR-INT-03): changing the approver's name in an approval and rehashing it yields a consistent chain link, but replay shows the result differs or breaks four eyes.

verifyChain is the lighter check without replay: each row is a readable entry (otherwise "the entry is not readable", with its position), each prevHash equals the previous hash, and each entry's contents hash to its hash. countersign verify runs both and reports them on separate lines.

4.3 While a server runs (Service.sync)

Before every read and write the server asks the store for its head (number and hash of the newest entry):

Observation Result
More entries than read so far The new ones are replayed (4.2)
Fewer entries than read so far Fault: "the store holds n entries, fewer than the m already read: entries were removed"
Same number, different newest hash Fault: "the newest entry in the store is not the one read before: the trail was rewritten"
Same number, same newest hash Nothing to do

On a fault the server keeps serving the state before the faulty entry for reading, refuses every write with HTTP 503, and /api/health answers 503 with status: "integrity-fault" and the entry number and message. The fault stays until the process is restarted against a store that verifies.

5. How the store enforces append-only, and the limits

Store Enforcement Who can get around it
PostgreSQL Trigger countersign_entries_no_change raises on every UPDATE and DELETE; countersign_entries_no_truncate on TRUNCATE; countersign_entries_chain refuses an INSERT whose seq is not the maximum + 1 or whose prev_hash is not the newest hash; primary key on seq; unique hash. With the recommended roles the application's account has only SELECT and INSERT The table's owner and superusers: they can disable or drop triggers, or drop the table
SQLite Triggers refuse UPDATE and DELETE and an INSERT that does not continue the chain Anyone who can write the file
File The adapter appends only and checks continuity itself Anyone who can write the file
Memory The adapter checks continuity; nothing persists Not for records

What is detected, by what, and when:

Manipulation Blocked by Detected by When
UPDATE, DELETE, TRUNCATE with the application's database account Missing privilege and triggers Not applicable At once
A field of an old entry changed in the entry text (triggers disabled by an administrator) Nothing Replay and verifyChain: the contents no longer match the hash At the next server start, at the next countersign verify, and when the standalone script is run on an export. Not by a server that already read the entry and keeps running
An old entry changed and its hash recomputed Nothing The next entry's prevHash no longer matches; if all later hashes are recomputed too, see the last row As above
An entry removed from the middle, or two swapped Nothing Numbering and chain no longer fit As above; a running server notices the lower count at once
An entry added at the end past the application The insert trigger demands continuity Replay: the row must be a readable entry whose command passes the rules and reproduces its hash When a server next reads it (the next request)
The newest entries removed (or the database restored from an older backup) Nothing A running server notices the lower count or the changed head at once. After a restart: only the comparison with an anchored head hash (section 7) Depends on anchoring
The whole trail replaced by another consistent history, rule-conformant and with recomputed hashes Nothing Only the comparison with an anchored head hash, or with an independent copy Depends on anchoring
The convenience columns of the PostgreSQL table (at, actor, action, record_id, record_type) or inserted_at changed while entry is left alone Nothing Not by the application, which reads only entry, seq and, for the head, hash. By the SQL check in section 6.4 When the check is run. The view countersign_audit_trail is not affected: it takes every column from the hashed entry text, except inserted_at. Only queries written directly against the table's plain columns would show the altered values

In short: the trail is tamper-evident for anyone who verifies, not tamper-proof against a privileged administrator; and evidence of truncation or wholesale replacement requires a reference kept outside that administrator's reach.

6. Independent verification

6.1 With the command-line tool

countersign verify

reads every entry directly from the store (not from a running server), and prints:

entries read    <n>
hash chain      intact
replay          all <n> entries reproduce
head hash       <64 hex>

or hash chain BROKEN at #<n>: <message> and replay FAILED at #<n>: <message>, with exit code 1. Two options compare with an anchor (section 7), each exiting with 1 on a mismatch:

  • --expect-entry <n>:<hash> checks that entry n still has that hash, however much the trail has grown since. It adds the line anchor #<n> matches, or anchor #<n> DOES NOT MATCH (entry #<n> has <hash>), or … (entry #<n> does not exist) when the trail is shorter than the anchor.
  • --expect-head <hash> compares with the newest entry only: expected head matches or expected head DOES NOT MATCH <hash>. Use it when the trail must not have grown, for example on a backup restored into a scratch database.

countersign status prints the number of entries, the head hash, the configuration's SHA-256 and integrity ok or integrity FAULT at #<n>: <message>.

Run verify on a host and under an account that the database administrators do not control, against a read-only database account if possible.

6.2 Standalone script

The script below uses no Countersign code. It needs Node.js (any current version) and an export made with countersign export --what audit --format jsonl --out audit.jsonl, or a dump of the entry column ordered by seq, one entry per line. It checks numbering, chain, content hashes and time order, and optionally that a given entry has an anchored hash. It does not re-run the rules; that is what countersign verify adds.

// verify-chain.mjs: node verify-chain.mjs audit.jsonl [<anchored seq> <anchored hash>]
import { createHash } from "node:crypto";
import fs from "node:fs";

// Canonical JSON: object keys sorted, no whitespace, undefined members left out.
const canonical = (v) => {
  if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
  if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`;
  return `{${Object.keys(v).filter((k) => v[k] !== undefined).sort()
    .map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`;
};

const [file, anchorSeq, anchorHash] = process.argv.slice(2);
const fail = (msg) => { console.error(`FAILED: ${msg}`); process.exit(1); };
let prev = "0".repeat(64); // the genesis value
let n = 0;
let lastAt = "";
let anchorSeen = false;
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
  if (!line.trim()) continue;
  const entry = JSON.parse(line);
  n++;
  const { hash, ...rest } = entry;
  const expected = createHash("sha256").update(entry.prevHash + canonical(rest), "utf8").digest("hex");
  if (entry.seq !== n) fail(`entry ${n} carries the number ${entry.seq}`);
  if (entry.prevHash !== prev) fail(`entry ${n} does not point at the entry before it`);
  if (hash !== expected) fail(`entry ${n}: its contents do not match its hash`);
  if (Date.parse(entry.at) < Date.parse(lastAt)) fail(`entry ${n} is dated before entry ${n - 1}`);
  if (anchorSeq && n === Number(anchorSeq)) {
    if (hash !== anchorHash) fail(`entry ${n} is not the anchored one: ${hash}`);
    anchorSeen = true;
  }
  prev = hash;
  lastAt = entry.at;
}
if (anchorSeq && !anchorSeen) fail(`the trail has only ${n} entries; the anchored entry ${anchorSeq} is missing`);
console.log(`${n} entries, chain intact${anchorSeq ? `, anchor #${anchorSeq} confirmed` : ""}, head ${prev}`);

Expected output on an intact trail: <n> entries, chain intact, head <hash>, with the same number and hash as countersign status.

6.3 Re-implementing it in another language

The algorithm, for an auditor who wants no dependence on JavaScript:

  1. Read the entries in order of seq. Set prev to 64 zeros and n to 0.
  2. For each entry: increase n; require entry.seq = n and entry.prevHash = prev.
  3. Remove the member hash from a copy of the entry. Serialise the copy as canonical JSON (section 3.2).
  4. Compute SHA-256 over the UTF-8 bytes of entry.prevHash followed by that text. Require the lowercase hexadecimal digest to equal entry.hash.
  5. Set prev to entry.hash. After the last entry, prev is the head hash.

Two points need care outside JavaScript. Numbers must be written as JavaScript writes them (a float such as 1e-7 is 1e-7, not 1e-07); integers and ordinary decimals are unproblematic. Strings must not be escaped beyond what section 3.2 lists (in Python: json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False), which reproduces the hashes of the demo data).

6.4 SQL checks on PostgreSQL

These complement, and do not replace, the checks above. Run them with a read-only account.

The plain columns exist for indexes and ad-hoc queries and are not covered by the hash chain. The view countersign_audit_trail does not use them (it reads the hashed entry, and adds signature_meaning and inserted_at), so reports should be built on the view. Plain columns equal the hashed entry (expected: no row):

SELECT seq FROM countersign_entries
WHERE (entry::jsonb ->> 'seq')::bigint <> seq
   OR (entry::jsonb ->> 'at')::timestamptz <> at
   OR entry::jsonb ->> 'actor' <> actor
   OR entry::jsonb ->> 'action' <> action
   OR entry::jsonb ->> 'recordId' <> record_id
   OR entry::jsonb ->> 'recordType' <> record_type
   OR entry::jsonb ->> 'prevHash' <> prev_hash::text
   OR entry::jsonb ->> 'hash' <> hash::text;

Numbering without gaps (expected: true):

SELECT count(*) = max(seq) AND min(seq) = 1 FROM countersign_entries;

Application time against the database's own clock (expected: no row beyond your tolerance; entries written by an import of historical demo data are an exception):

SELECT seq, at, inserted_at, inserted_at - at AS difference
FROM countersign_entries
WHERE abs(extract(epoch FROM inserted_at - at)) > 5
ORDER BY seq;

Triggers present and enabled: see the IQ protocol, step 6.2.

7. Head-hash anchoring

Purpose: to make truncation, rollback to an old backup and wholesale replacement of the trail evident. The head hash depends on every entry before it, so a head hash recorded where the database administrators cannot change it pins the whole history up to that entry.

7.1 Taking an anchor

  1. Run countersign status (or read /api/health). Note entries (the number N of the newest entry) and head hash (H). Confirm integrity ok.
  2. Record the date and time, N, H, the configuration SHA-256 and who took the anchor.
  3. Store the record in at least one place that people with write access to the database cannot alter. Examples: a signed entry in a paper logbook; a ticket or document in the quality system; an e-mail to a quality mailbox; a write-once storage bucket; a qualified time-stamp over the record.

Interval: by risk; daily or weekly for an active system, and always before and after a backup restore, a migration, a software upgrade and a configuration change. An anchor is only as good as the independence of where it is kept.

7.2 Verifying against an anchor

  • Run countersign verify --expect-entry N:H. Expected: hash chain intact, replay all <n> entries reproduce and anchor #N matches, exit code 0. This proves that entry N still has hash H, that everything before and after it is one unbroken chain, and that every command still passes the rules. It works whether or not the trail has grown.
  • Independent method, without Countersign code: export the trail (countersign export --what audit --format jsonl --out audit.jsonl) and run node verify-chain.mjs audit.jsonl N H. Expected: chain intact, anchor #N confirmed. Use it at least at each audit trail review, on a host the database administrators do not control.
  • A mismatch or a missing entry N means entries up to N were changed or removed after the anchor was taken: treat as an integrity incident (SOP templates, SOP 6).

7.3 Anchor register (template)

Date, time (UTC) Entries N Head hash H Configuration SHA-256 Taken by Previous anchor verified (yes/no, by) Stored at

8. ALCOA+ mapping

Attribute What it asks, briefly How the design supports it What the organisation must add Limits
Attributable Who did it, and when Every entry carries the person's id, name and roles from the identity provider, the time, and how they were identified (auth); the actor is taken from the session only (FR-AUD-01, FR-AUD-04, FR-SIG-08). Versions name author, confirmer and approver Personal accounts; unique, never reused identifiers; control of the command-line tool, whose actor is stated by the operator No device or IP address in the entry. Imported records carry the source's approver as unverified text
Legible Readable and understandable for the retention period Plain JSON with named fields; the interface shows history and differences (FR-UI-04); exports in JSON, JSON Lines and CSV (FR-EXP-01); record names resolved in the CSV A way to read JSON for inspectors; retention of the configuration with the data (it is inside the trail) The CSV summarises each change in words (it has the signature's meaning but not the full before and after values). Field keys are technical names from the configuration; labels are in the configuration document
Contemporaneous Recorded at the time of the activity The entry is written in the same operation as the change; there is no way to change data without an entry, and none to backdate one (FR-AUD-01, FR-AUD-03, FR-OPS-04) Synchronised clocks; users act in the system when the activity happens, not afterwards from notes The time is the server's. If the clock is behind the newest entry, the newest entry's time is used. Migrated history carries the migration's time, with the source's dates as data
Original The first capture, or a verified true copy The trail is the original and only record; state is derived from it (FR-INT-06). Exports are copies whose fidelity can be proven with the hash chain Procedure for verified copies; keeping migration source files Data transcribed from paper is only as original as your process makes it
Accurate Correct, with changes controlled Field validation at every stage (FR-VAL-01 to FR-VAL-07); second-person approval before effect (FR-WF-04); changes only as new versions with reasons (FR-VER-01, FR-WF-09) Sensible field rules in the configuration; diligent approvers The software checks form, not truth
Complete Everything, including repeats and changes; nothing deleted One entry per accepted command; all versions kept; removed records stay in the trail (FR-AUD-01, FR-VER-02, FR-LIFE-02); gaps in numbering are detectable (FR-INT-04) Anchoring, so that truncation is evident; review Refused commands, reads and failed sign-ins are not recorded. The reason is optional on create and on draft edits; reasons and meanings are limited to 2000 characters
Consistent In sequence, with coherent times Strict numbering; times never decrease; the same rules for browser, server and replay (FR-AUD-03, FR-INT-06); two servers cannot fork the trail (FR-STO-05) One time source A clock set ahead stamps all later entries with that time until real time passes it
Enduring Preserved for the retention period Append-only store with database-level enforcement (FR-STO-03, FR-STO-04); open, documented formats Backup, restore tests, archive, media No archive function; durability is the database's
Available Retrievable for review and inspection when needed Search, filter and export of the trail (FR-UI-06); per-record history (FR-UI-04); read access for every signed-in person; reading continues during an integrity fault (FR-OPS-02) Access for reviewers and inspectors; availability of the infrastructure; continuity plan All of the trail is loaded to work with it; very large trails need measuring (PQ protocol, section 6)

9. Audit trail review checklist

Use with the review procedure (SOP templates, SOP 2). Period reviewed: ________ to ________. Entries ________ to ________. Reviewer: ________________.

9.1 Integrity of the trail

No. Check How Result OK / Finding
1 The trail verifies countersign verify: chain intact, all entries reproduce
2 The previous anchor still holds Section 7.2
3 Numbering continues from the last review without gap First entry of this period = last reviewed + 1
4 /api/health reported ok throughout, or each interruption has an incident record Monitoring history
5 Triggers present and enabled; no change to database roles IQ queries 6.2, 6.9, 6.12; database DDL log
6 Query columns equal the entries; times agree with the database clock Section 6.4
7 A new anchor is taken at the end of the review Section 7.1

9.2 Content of the trail

No. Check How (filter) Result OK / Finding
8 Every configuration change matches an approved change record: for approve-config entries the proposer and the approver are two different people entitled to it; for configure entries the reviewer named in the command is the one who signed the change record; the configuration hash equals the reviewed one; rejected and withdrawn proposals are explained Record type = Configuration
9 Every import entry belongs to an approved migration batch with a signed reconciliation Action = import
10 Every entry with sign-in method cli or system is explained by a change record; no entry has method demo; the method is otherwise the approved one auth_method column of the CSV
11 Every step back from Approved (revoked approval) has a meaningful reason and, where your procedure requires, a deviation or change record Action = step-back, before state approved
12 Every retire and remove has a meaningful reason Action = retire, remove
13 Reasons on changes of approved records are meaningful; sample ____ % Action = edit with a reason
14 Every approval carries the expected signature meaning Action = approve; column signature_meaning of the CSV
15 Approvals very shortly after confirmation (for example under one minute) are plausible Compare times of confirm and approve per version
16 No pair of people approves each other's changes to an extent that suggests routine counter-signing; no single approver signs an implausible volume Count by author and approver
17 People acted only with roles they were entitled to at the time actor_roles against the access register for the period
18 No action by a person who had left or changed function Actors against the leaver list
19 Actions at unusual times (nights, weekends, holidays) are explained Sort by time
20 Authentication times on approvals are within the maximum sign-in age before the approval auth_time against at_utc for action = approve
21 Open versions older than ____ days (drafts and changes awaiting approval) are followed up Pending items in the application
22 Option values reported as no longer fitting their definition are resolved Option views in the application
23 Refused and unauthenticated attempts in the web-server log (status 401, 403, 422 on /api/act) show no pattern of probing Web-server log; identity-provider sign-in log

9.3 Outcome

Item Entry
Findings and their record numbers
New anchor (N, H)
Export of the reviewed period filed at
Reviewer, signature, date
QA review, signature, date