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") ) ) )
prevHashis 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 memberprevHash.+is string concatenation with nothing in between.- For entry 1,
prevHashis the genesis value0000000000000000000000000000000000000000000000000000000000000000.
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.
3.4 Related fingerprint
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:
- The entry must be an object with a
command; otherwise "the entry is not readable". - The stored
commandis run again through the engine as the stored actor (actor,actorName,actorRoles), at the stored timeat, with the storedauth, 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). - 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 storedhash. 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 lineanchor #<n> matches, oranchor #<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 matchesorexpected 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:
- Read the entries in order of
seq. Setprevto 64 zeros andnto 0. - For each entry: increase
n; requireentry.seq = nandentry.prevHash = prev. - Remove the member
hashfrom a copy of the entry. Serialise the copy as canonical JSON (section 3.2). - Compute SHA-256 over the UTF-8 bytes of
entry.prevHashfollowed by that text. Require the lowercase hexadecimal digest to equalentry.hash. - Set
prevtoentry.hash. After the last entry,previs 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
- Run
countersign status(or read/api/health). Noteentries(the number N of the newest entry) andhead hash(H). Confirmintegrity ok. - Record the date and time, N, H, the configuration SHA-256 and who took the anchor.
- 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 reproduceandanchor #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 runnode 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 |