Architecture
Countersign stores one thing: an append-only, hash-chained list of audit entries. Everything else (records, versions, option values, the configuration in force, who may do what) is computed by replaying that list through one rules engine, a pure function that runs unchanged in the browser, on the server, in the command-line tool and in the tests. This page explains that design, what it protects against and where its limits are.
Contents
- The idea in five sentences
- Building blocks
- The engine
- Commands
- The version state machine
- The audit entry and the hash chain
- Replay
- The configuration is part of the trail
- The two client backends
- Request flow of one approval
- Concurrency
- Trust boundaries
- Design decisions and trade-offs
- Limits
The idea in five sentences
- Every change is a command (create, edit, confirm, approve, …) issued by an identified person.
- One function,
act(db, actor, command, time), decides whether the command is allowed and, if so, returns the new state and exactly one audit entry. - Each entry carries the SHA-256 hash of its content and of the entry before it, so the entries form a chain.
- The store keeps the entries and nothing else; the state is whatever replaying the entries from the first one gives.
- Replay re-runs every command and compares the result with the stored entry, so an entry that was altered, removed, reordered or that the rules would not have allowed is found.
Building blocks
flowchart TB
accTitle: Building blocks: the browser, the server and the store
subgraph browser["Browser"]
direction LR
ui["components/<br/>React UI"]
cstore["components/store.tsx<br/>local backend: engine + localStorage<br/>remote backend: fetch, replay, verify"]
libb["lib/<br/>the same engine"]
ui --> cstore --> libb
end
subgraph server["Server (Node.js)"]
direction LR
routes["app/api/*, app/auth/*<br/>routes"]
handlers["server/app.ts<br/>HTTP handlers"]
auth["server/auth/<br/>session, OIDC"]
service["server/service.ts<br/>replay, cache"]
storage["server/storage/<br/>Store adapters"]
libs["lib/<br/>the engine"]
routes --> handlers
handlers --> auth
handlers --> service
service --> libs
service --> storage
end
entries[("countersign_entries<br/>append-only table,<br/>file or memory")]
cli["scripts/countersign.ts<br/>command-line tool"]
cstore <-- "HTTP" --> routes
storage <--> entries
cli --> service
cli --> libs
The local backend of components/store.tsx never talks to a server: it runs the engine in the browser and keeps the
entries in localStorage. The command-line tool uses server/service.ts and lib/ directly, without HTTP.
| Folder | Contents | Depends on |
|---|---|---|
lib/ |
Types, configuration checks, the engine, option resolution, presentation helpers, migration, CSV, demo presets and seed. No DOM, no Node-only APIs. | nothing |
server/ |
Settings from the environment, storage adapters, the service (replay and cache), sessions and OIDC, the HTTP handlers written against the standard Request/Response. |
lib/, pg, jose, Node |
app/ |
Next.js routes: the pages and thin route files that call server/app.ts. |
server/, components/ |
components/ |
The React user interface and its state (store.tsx). |
lib/ |
scripts/ |
The command-line tool, the plain Node API server, the traceability and report tools. | lib/, server/ |
The engine
lib/engine.ts exports the function everything else is built around:
function act(db: Db, actor: Actor, cmd: Command, at: string, opts?: { auth?: AuthEvidence }): ActResult
type ActResult = { db: Db; entry: AuditEntry } | { error: string };
- It is pure: no clock, no randomness, no I/O. The caller supplies the time. The same inputs always give the same entry with the same hash.
- It never mutates
db; it returns a new one. - A refusal is a value (
{ error: "Four eyes: you wrote VH-0048 v1, so someone else has to approve it." }), not an exception, and writes nothing.
The state it works on:
interface Db {
config: DomainConfig | null; // the configuration in force; null until the first "configure" entry
configRev: number; // how many times the configuration was set
records: Record<string, Rec>; // id -> { id, type, versions[] }
counters: Record<string, number>; // per record type, for the next id
audit: AuditEntry[]; // the trail
people: Record<string, { name: string; roles: string[] }>; // everyone who ever acted
}
Because the engine is the same code everywhere, the buttons in the user interface never disagree with the rules: the
UI asks the engine with a dry run which actions a person may take (allowedActions in lib/present.ts), and the
server runs the very same function on the very same state when the command arrives.
Commands
The complete list (Command in lib/types.ts):
| Command | Fields | What it does | Who may |
|---|---|---|---|
create |
type, data, note? |
New record, version 1 in state Created. | author permission for the type (for values: the level). |
edit |
id, data, note? |
Created: changes the draft in place. Approved: opens a new version, already Confirmed by the editor, with note as the required reason. Confirmed: refused (locked). |
author permission; a draft only by its author. |
confirm |
id |
Created → Confirmed: "ready for approval". | The version's author. |
approve |
id, meaning |
Confirmed → Approved. The version takes effect. meaning states what the signature means. |
approve permission, and not the author or confirmer. |
step-back |
id, reason |
Approved → Confirmed (revokes the approval) or Confirmed → Created (reopens the draft). | Approved: approve permission. Confirmed: the author or an approver. |
retire |
id, reason |
Opens a version that marks the record as retired: no longer in effect, still shown. Needs approval. | author permission. |
remove |
id, reason |
A never-approved draft is removed at once. An approved record gets a version marking it removed, which needs approval. | Draft: author or approver. Approved record: author permission. |
import |
type, data, origin |
New record, version 1 already Approved, marked with where it came from. | import permission. Command line only. |
configure |
config, reason, reviewer? |
Sets the configuration in one step. | First time: anyone (the system). Later: configure permission, with a reviewer named by the operator. Command line only. |
propose-config |
config, reason |
Stores a configuration change as the pending proposal. Nothing changes yet. | configure permission. Refused while it would strand existing records. |
approve-config |
hash, meaning |
The pending proposal takes effect; the revision counts up. A signature. | configure permission, and not the proposer. |
reject-config |
hash, reason |
Drops the pending proposal. | configure permission, and not the proposer. |
withdraw-config |
reason? |
Drops the pending proposal. | The proposer. |
The JSON shapes are in the API reference.
The version state machine
A record is a list of versions. The newest approved version is the one in effect; a newer version that is not yet approved is open; older approved versions are shown as superseded.
stateDiagram-v2
accTitle: The states of one version: Created, Confirmed, Approved
direction LR
[*] --> Created: create
Created --> Created: edit (author)
Created --> Confirmed: confirm (author)
Confirmed --> Created: step-back
Confirmed --> Approved: approve (by someone else)
Approved --> Confirmed: step-back (revoke)
[*] --> Approved: import
note right of Approved
In effect. A change does not
alter it: it opens version v+1,
Confirmed by its author,
which waits for approval.
end note
One version moves through these states. A change to an approved record is a second version beside the first:
flowchart LR
accTitle: Editing an approved record opens a new version while the approved one stays in effect
subgraph one["Version v"]
direction LR
c1["Created"] -- "confirm" --> f1["Confirmed"]
f1 -- "approve" --> a1["Approved:<br/>in effect"]
end
subgraph two["Version v+1"]
direction LR
f2["Confirmed by its author:<br/>open, waiting"] -- "approve" --> a2["Approved:<br/>in effect"]
end
a1 -- "edit / retire / remove,<br/>with a reason" --> f2
a2 -. "v is now superseded" .-> a1
- Created is a draft only its author can change or confirm.
- Confirmed is locked. It can be approved by someone else or stepped back to Created with a reason.
- Approved is in effect. Changing it never alters the approved version: an edit opens version v+1, which waits as
Confirmed while v stays in effect. Retiring and removing work the same way, with a lifecycle (
active,retired,removed) on the version. - At approval the data is validated again against the state as it is then (a unique value may have been taken in the
meantime):
… can't be approved any more: …. - An import produces version 1 directly in Approved, with
origininstead of an approver.
The audit entry and the hash chain
One entry per accepted command (AuditEntry in lib/types.ts):
| Field | Content |
|---|---|
seq |
1, 2, 3, … without gaps. |
at |
Time of the action, ISO 8601 in UTC, from the server's clock. |
actor, actorName, actorRoles |
The person's stable id, their name and their roles at that moment. |
action |
The command's kind. |
recordId, recordType |
The record acted on. Configuration entries use CONFIG and config. |
v |
The version the action produced or changed. |
before, after |
What changed: data for create and edit, the state or lifecycle for the others. |
reason |
The reason or note, where one was given. Notes, reasons and signature meanings are text of at most 2000 characters. |
command |
The command exactly as given, so that the entry can be re-run. |
auth |
How the server knew who was acting: method, issuer, authTime. Added by the server, never taken from the browser. |
prevHash |
The hash of the entry before; 64 zeros for the first entry. |
hash |
The entry's own hash. |
Exact construction
// lib/engine.ts
export function stable(v: unknown): string {
if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
if (Array.isArray(v)) return `[${v.map(stable).join(",")}]`;
const o = v as Record<string, unknown>;
return `{${Object.keys(o).filter((k) => o[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${stable(o[k])}`).join(",")}}`;
}
export function hashEntry(e: AuditEntry | Omit<AuditEntry, "hash">): string {
const rest: Record<string, unknown> = { ...e };
delete rest.hash;
return sha256(e.prevHash + stable(rest));
}
In words:
- Take the entry without its
hashfield (prevHashstays in). - Serialise it with
stable: JSON without white space, object keys sorted (by UTF-16 code unit, JavaScript's default sort), properties whose value isundefinedleft out, arrays in their order, strings and numbers asJSON.stringifywrites them. - Prefix the result with the previous entry's hash (64 lowercase hex characters; 64 zeros for entry 1).
- The entry's hash is the SHA-256 of that string encoded as UTF-8, as 64 lowercase hex characters.
Sorting the keys makes the hash independent of the key order a database or a JSON library happens to produce. The
configuration's fingerprint shown by status and /api/health is built the same way: sha256(stable(config)).
The chain can be checked without any Countersign code. This script verifies an exported trail
(npm run cli -- export --out trail.jsonl):
// check-chain.mjs: node check-chain.mjs trail.jsonl
import { createHash } from "node:crypto";
import fs from "node:fs";
const stable = (v) =>
v === null || typeof v !== "object"
? (JSON.stringify(v) ?? "null")
: Array.isArray(v)
? `[${v.map(stable).join(",")}]`
: `{${Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(",")}}`;
let prev = "0".repeat(64);
for (const line of fs.readFileSync(process.argv[2], "utf8").split("\n").filter(Boolean)) {
const { hash, ...rest } = JSON.parse(line);
const expected = createHash("sha256").update(rest.prevHash + stable(rest), "utf8").digest("hex");
if (rest.prevHash !== prev || expected !== hash) throw new Error(`entry #${rest.seq} does not verify`);
prev = hash;
}
console.log(`chain intact, head ${prev}`);
$ node check-chain.mjs trail.jsonl
chain intact, head 7cdb4906fe01bb67f5e417696f80a7da76d3738b1445d2643fd21bc67741d708
The hash of the newest entry, the head hash, commits to the whole history: change, remove or reorder anything and the head hash changes.
Replay
replay(base, entries) runs each stored entry's command through act as the stored actor at the stored time, and
compares the entry that comes out with the stored one. It stops at the first entry that fails and says why:
| Finding | Meaning |
|---|---|
the entry is not readable |
Not an object, or it has no command. |
it breaks a rule: <engine message> |
The rules would not have allowed this command in that state: the entry was not produced by the engine, or entries before it are missing. |
its contents were changed after it was written (the hash no longer matches) |
The stored fields do not hash to the stored hash. |
the chain is broken: it doesn't point at the entry before it |
prevHash is not the hash of the preceding entry. |
re-running its command gives a different result |
The stored hash is consistent with the stored fields, but the engine produces different content from the command. |
This is stronger than checking hashes alone. A chain of correct hashes can be fabricated by anyone who can write to the
store; a chain in which every entry is also the result the rules give is only possible for histories the rules allow.
verifyChain is the hash-only check; the CLI's verify runs both.
Replay is used:
- by the server when it starts and whenever new entries appear in the store (
server/service.ts); - by the browser on every entry it receives;
- by
npm run cli -- verify, from the first entry, independent of any running server; - by the tests.
The configuration is part of the trail
The record types, fields and roles are set by a command, configure, whose entry contains the complete configuration
document. Entry 1 of every trail is the initial configuration; each later change is another entry with the fingerprint
before and after. Replay therefore applies each entry under the configuration that was in force when it was written,
and a configuration change is as traceable as a change to a record: who, when, why, reviewed by whom. See
Configuration.
The two client backends
components/store.tsx offers the user interface one interface with two implementations. Which one is used is decided
by the server's answer to GET /api/bootstrap.
| Local (demo) | Remote (server mode) | |
|---|---|---|
| Chosen when | "mode": "demo": no COUNTERSIGN_STORAGE |
"mode": "server" |
| Where the trail lives | The preset's starting history is rebuilt in the browser by lib/seed.ts (deterministic: same entries and hashes every time); the visitor's own entries are kept on top of it in localStorage. |
In the server's store. |
| Who runs the engine | The browser. | The server decides; the browser replays what it receives. |
| Who you are | Picked from the preset's list of people. | From the identity provider (or, with COUNTERSIGN_AUTH=demo, a list: the preset's people or COUNTERSIGN_DEMO_USERS). |
| Reading | From memory. | GET /api/entries?after=N, repeated every 15 seconds and after each own action. |
| Writing | act in the browser, then save to localStorage. |
POST /api/act; the server returns the entry or the engine's refusal. |
| Integrity | The browser replays its stored log on load and reports a broken one. | The browser replays and verifies every entry it is sent, so a server or store that serves an inconsistent trail is noticed by the client too. |
The localStorage keys of the local backend are countersign:preset, countersign:log:v1:<preset> and
countersign:user:<preset>. Nothing leaves the browser in demo mode.
Request flow of one approval
Server mode with OpenID Connect sign-in, an approval by Alice of a version written by Bob.
sequenceDiagram
accTitle: Request flow of one approval, with a fresh sign-in at the identity provider
participant B as Alice's browser
participant S as Countersign server
participant D as Store
participant I as Identity provider
B->>S: POST /api/act with the approve command, the session cookie and X-Countersign-Request: 1
Note over S: check header and Origin (CSRF)<br/>verify cookie signature and expiry
opt the sign-in is older than 15 minutes
S-->>B: 401 with code reauth_required and loginUrl /auth/login?fresh=1
B->>S: GET /auth/login?fresh=1 (and where to return to)
S-->>B: 302 to the provider with max_age=0, prompt=login, PKCE, state, nonce
B->>I: Alice proves her identity again
I-->>B: 302 to /auth/callback with code and state
B->>S: GET /auth/callback
S->>I: exchange the code
I-->>S: ID token, which the server verifies
S-->>B: 302 back to the app, new session cookie with the new authTime
B->>S: POST /api/act (the same command)
end
S->>D: head()
D-->>S: read(after) if there are new entries, which the server replays
Note over S: act(db, alice, approve, now, {auth})<br/>state Confirmed? may approve? four eyes?<br/>data still valid? Then entry N with its hash
S->>D: append(entry N)
Note over D: primary key and chain trigger accept
D-->>S: appended
S-->>B: 200 with the entry
Note over B: replay the entry locally, compare the hash,<br/>update the screen
If the sign-in is fresh enough, the boxed part is skipped: the first request is answered directly. If the engine refuses, the answer
is 422 {"error": "<the engine's message>"} and nothing is written.
Concurrency
There are three layers, from the inside out.
- One command at a time per process.
Servicequeues reads and writes (serial), so two requests to the same server never runacton the same state at the same time. - The store settles races between processes.
Store.append(entry)succeeds only ifentry.seqis the head plus one andentry.prevHashis the head's hash. In PostgreSQL this is enforced twice: by the primary key onseqand by a trigger that compares the new row with the last one. Of two servers that append entry N at the same moment, exactly one succeeds. - The loser retries. A refused append makes the service read the entry that won, replay it, and run the command
again on the new state: up to six attempts, then
409 Too many people are writing at once; try again.The second run may now be refused by the rules (the record was approved by the other person a moment earlier), which is the correct outcome.
The trail therefore never forks, and every entry was computed on exactly the state that precedes it. Times never go backwards either: the service uses its clock but never a time earlier than the newest entry's, so two servers whose clocks differ slightly still produce a monotonic trail.
Trust boundaries
| Boundary | What is trusted | What it protects against | What it does not protect against |
|---|---|---|---|
| Browser → server | Nothing from the browser except the command. Identity comes from the session cookie or proxy headers; the time from the server's clock; permissions from the engine. | A user forging who they are, when they acted, or what they may do; a modified client. | A person using a colleague's unlocked session. The re-authentication before an approval narrows this for signatures. |
| Server → identity provider | The provider's signed ID token (signature, issuer, audience, expiry, nonce) and its statement of who the person is and which roles they hold. | Stolen authorisation codes (PKCE), replayed responses (state, nonce), tokens from another application. | An administrator of the identity provider assigning roles wrongly. Role assignment is governed there, not here. |
| Server → store | The store is not trusted to be honest: every entry read is replayed and checked. | Edits to stored entries, removed or inserted entries, a fork: all are found at the next start, on the running server when the head changes, and by verify. |
See the next rows. |
| Database role | The application's role has INSERT and SELECT only; triggers refuse UPDATE, DELETE and TRUNCATE and rows that do not continue the chain. | A compromised application, SQL injection, a careless operator with the application's credentials changing history. | The table's owner or a superuser, who can disable triggers. |
| Database owner / superuser | Not prevented, but detected: a change to any stored entry breaks its hash; verify and the next server start report it. |
Quiet edits of single entries. | Rewriting the whole chain: someone who can disable the triggers, has the source code and replaces every entry from some point on with a recomputed, rule-conforming chain produces a trail that verifies. So does truncating the newest entries while no server is running. Only a head hash kept outside the database reveals this. |
| The anchored head hash | A copy of the head hash stored where database administrators cannot change it (a signed report, a WORM store, another system). verify --expect-head <hash> compares the head against it; verify --expect-entry <n>:<hash> checks an older anchor after the trail has grown. |
The rewrite and the truncation above, back to the last anchor. | Changes made after the last anchor. Anchor as often as your risk assessment asks. |
| Command line | Whoever can run the CLI with database credentials states who they are (--actor-id); the operating-system account and host are recorded in the entry. |
Anonymous imports and configuration changes: every one is an entry with a name and a reason. | Someone with shell and database access giving a false name. Restrict that access and govern it by procedure. |
See Operations for anchoring in practice and Security for the full threat model.
Design decisions and trade-offs
| Decision | Why | Cost |
|---|---|---|
| The audit trail is the only stored data. | The trail cannot disagree with the records, because the records are derived from it. Any state in the past can be rebuilt. Storage adapters are tiny: five methods. | State must be replayed on start (fast: about 2 s for 30,000 entries) and held in memory. No ad-hoc SQL over records (the reporting view covers the trail; export covers records). |
| One pure engine, shared. | The rules exist once. Tests exercise exactly what production runs. The client can verify the server. | The engine must stay free of I/O and platform APIs, including its own SHA-256 (Web Crypto's is asynchronous). |
| The entry stores the command, and replay re-runs it. | Verification covers the rules, not only the hashes. | A change to the engine that alters the outcome of an old command would make old trails fail replay. Engine changes must keep old entries reproducible; the tests replay the presets' histories to guard this. |
| The configuration is a command in the trail. | Configuration changes are audited and old entries replay under the rules of their time. | No in-app configuration editor; changes go through the command line under change control. |
| Sessions are signed cookies without server state. | Any number of servers behind a load balancer, nothing to store or replicate. | A session cannot be revoked individually before it expires; roles are those at sign-in. |
| Import and configure are command-line only. | The two most powerful actions need database credentials and leave the web surface small. | Operators need shell access to a host that reaches the database. |
| Whole-trail reads to the browser. | The browser can verify everything and work without a server round trip per screen. | Every signed-in person can read everything; large trails mean large first loads. |
| Reference data by id, validated at write time. | Simple, and enough for hierarchies. | No cascading changes: a record must be moved or retired before its parent can be. |
Limits
- Size. The whole trail is replayed at start and the whole state is held in memory by every server process. Replay works in place on an indexed copy of the state and takes time in proportion to the number of entries: measured on a development machine with Node 22, 3,010 entries replay in 0.23 s, 9,010 in 0.72 s and 30,010 in 2.0 s, with about 3 MB of heap per thousand entries. The browser downloads and replays the whole trail on first load too, and there it is the transfer and the device that set the limit. Measure with your own data before going beyond tens of thousands of entries. See Deployment.
- No snapshots, no archiving. There is no checkpoint from which replay could start, and entries cannot be moved out of the trail.
- No per-record read restrictions. See Security.
- One approval step. No multi-stage or quorum approval.
- No attachments or binary data.
- Time comes from the server's clock and is only as good as its synchronisation.
- Identity is a snapshot at sign-in: a role withdrawn at the identity provider remains in the session until it
expires (
COUNTERSIGN_SESSION_HOURS). - Tamper evidence, not tamper proofing. The database refuses changes from the application's role and detects changes by stronger roles; preventing a determined superuser needs the external anchor described above.