Functional specification
| Document id | CS-VAL-03 |
| Title | Functional 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 |
This document describes what Countersign 1.0.0 does and where in the source code it does it. It is written from the code; where the two differ, the code is right and this document has a defect to report. File paths are relative to the application folder. Requirement identifiers and texts are those of requirements.json; the traceability matrix links each to its test.
Messages in code style inside quotation marks are the exact refusal texts the engine returns. Parts in angle brackets
stand for values (a record id, a role name).
1. Structure of the software
| Part | Files | Role |
|---|---|---|
| Data model | lib/types.ts |
Types of configuration, record, version, command, audit entry, state |
| Configuration | lib/config.ts |
checkConfig (the one validator), permission checks canAuthor, canApprove, hasAdmin |
| Rules engine | lib/engine.ts |
act (every command), validate, stable, hashEntry, replay, verifyChain, configHash |
| Hash function | lib/sha256.ts |
SHA-256 of a string (UTF-8), synchronous, implemented in the repository |
| Option resolution | lib/resolve.ts |
chainFor, resolveFor |
| Presentation helpers | lib/present.ts |
allowedActions (dry runs of act), inbox, diff, auditCsv |
| Migration | lib/migrate.ts, lib/csv.ts |
checkMapping, runMigration, reportMarkdown, crossReferenceCsv, parseCsv |
| Migration assistant | lib/adopt/, scripts/migrate.ts |
parseSqlDdl, parsePrisma, draftFromModel, scanCapabilities, guideMarkdown, verifyMigration, appendEntry, checkJournal |
| Settings | server/settings.ts |
readSettings, settingsProblems: the COUNTERSIGN_* environment variables |
| HTTP handlers | server/app.ts |
bootstrap, entries, act, health, login, callback, logout, demoLogin |
| Service | server/service.ts |
Service: replayed state in memory, sync, submit, integrity fault |
| Sign-in | server/auth/oidc.ts, server/auth/session.ts |
OpenID Connect code flow; signed session cookie |
| Storage | server/storage/*.ts, db/postgres.sql |
Store interface; memory, file, SQLite, PostgreSQL adapters |
| Command-line tool | scripts/countersign.ts |
init, status, verify, config, import, export |
| Traceability | scripts/trace.ts, lib/trace.test.ts |
Builds and checks the matrix |
lib/ contains no browser or server dependency. The browser (demo path and user interface), the server and the tests
run the same engine code. In the demo path there is no server state: the browser keeps the audit trail in localStorage
and runs the engine itself; nothing in this document about storage, sign-in or the server applies to it, and it must
not hold regulated records.
2. Data model
2.1 Configuration (DomainConfig)
| Element | Content |
|---|---|
schema |
Always 1 |
id, name, description |
Identifier (lowercase slug), display name, optional text |
entities[] |
Record types: id (slug), label, plural, prefix (two to five capital letters, unique; OD and OV are taken), nameField, optional summary[], fields[] |
fields[] |
key, label, type (text, longtext, number, integer, boolean, date, select, ref), optional required, unique, choices (select), ref and role (parent, group, link) for references, min, max, pattern, patternHint, hint |
roles[] |
id, name, summary, author[], approve[] |
optionGroups[] |
Suggested group names for option definitions |
A permission is a record type id, definition, value@<record type id>, value@*, or * (every record type and
every option level). In author only, also import and configure; * does not include these two.
2.2 Record (Rec) and version (Version)
A record is { id, type, versions[] }. The id is <prefix>-<number padded to four digits>, numbered per type from a
counter that never goes back (nextId). Option definitions are OD-…, option values OV-….
| Version field | Meaning |
|---|---|
v |
Version number, from 1 |
state |
created, confirmed or approved |
lifecycle |
active, retired or removed: what this version makes of the record once approved |
data |
The field values (configured type), or the definition, or the value |
author |
Id of the person who wrote the version: the creator of v1, the editor whose change opened a later version, the person who asked to retire or remove, or the importer |
createdAt, updatedAt |
Times of the entries that created and last changed the version |
note |
Reason given for this version |
confirmedBy, confirmedAt |
Who confirmed and when |
approvedBy, approvedAt, meaning |
Who approved, when, and the stated meaning of the signature |
origin |
For imported versions: system, id, batch, optional approvedBy, approvedAt as the source recorded them |
Derived terms (functions in lib/engine.ts):
| Term | Definition | Function |
|---|---|---|
| Effective version | The newest version whose state is approved |
effective |
| Open version | The newest version, if it is not approved | openVersion |
| Superseded | An approved version that is not the effective one (a label, not a stored state) | versionLabel |
| Lifecycle of a record | The lifecycle of its effective version, or of its only draft if never approved | lifecycleOf |
| Live | Lifecycle active |
isLive |
| Removed | Lifecycle removed: left out of lists, the tree and resolution; accepts no command; kept in the trail |
isRemoved |
2.3 Built-in record types
| Type | Data | Rules (validate) |
|---|---|---|
Option definition (definition) |
name, group, kind (boolean, number, text, selection), default, choices[], unit, min, max, description |
Name and group filled; name unique among definitions not removed (case-insensitive); a selection has at least two distinct non-empty choices and other kinds have none; min not above max; the default passes checkValue; a change must not strand values in use (usageError) |
Option value (value) |
definitionId, level (a record type id), targetId, value |
Definition and target exist and are live; the value passes checkValue (type, range, choice, text up to 300 characters); one value per definition and target among values not removed; definition, level and target cannot change by edit |
2.4 State (Db)
{ config, configRev, records, counters, audit, people }. It is never stored. It is rebuilt by replay from the audit
entries, in the server (Service.sync) and in the browser.
3. Workflow state machine
States of one version, and the commands that move it. "Author" is the version's author; "approver" is a person whose
role grants approve for the record's type (for option values: at the value's level).
| From | Command | By | To | Notes |
|---|---|---|---|---|
| (none) | create | A person with author permission | v1 Created | |
| (none) | import | A person with import permission, command-line tool only |
v1 Approved | Marked with its origin |
| Created | edit | The author only | Created (same version) | Version number stays |
| Created | confirm | The author only | Confirmed | Data is validated again |
| Confirmed | edit | Nobody | Refused: locked | |
| Confirmed | approve | An approver who is neither author nor confirmer | Approved | Meaning required; data validated again |
| Confirmed | step-back | The author or an approver | Created | Reason required |
| Approved | step-back | An approver | Confirmed | Reason required; the previous approved version, if any, is in effect again |
| Approved (latest) | edit | A person with author permission | New version v+1, Confirmed | Reason required; the approved version stays in effect |
| Approved (latest), active | retire | A person with author permission | New version v+1, Confirmed, lifecycle retired |
Reason required; takes effect on approval |
| Approved (latest) | remove | A person with author permission | New version v+1, Confirmed, lifecycle removed |
Reason required; takes effect on approval |
| Never approved | remove | The author or an approver | Same version, lifecycle removed, at once |
Reason required |
| Removed | any | Nobody | Refused |
There is no command that deletes a version or a record, and none that changes an approved version's data.
4. Commands
Every command goes through act(db, actor, command, at, { auth }) in lib/engine.ts. It returns either a new state
plus the audit entry, or { error }; on an error nothing is changed and nothing is written (FR-AUD-02).
4.1 Checks common to all commands
| Check | Refusal |
|---|---|
| The actor has an id, a name and a list of roles | "Nobody is signed in." |
| The command is an object | "The command is missing." |
| The time is a valid date | "The time of the action is not valid." |
| The time is not before the newest entry | "The clock went backwards: entry #<n> was written at <time>." |
note, reason and meaning, where given, are text of at most 2000 characters |
"The reason must be text of at most 2000 characters." (for a meaning: "The meaning must be …") |
| A configuration exists (all commands except configure) | "The system has no configuration yet." |
| The record exists (commands on a record) | "Record <id> does not exist." |
| The record is not removed | "<id> was removed; it can only be looked at in the audit trail." |
4.2 create
| Command | { kind: "create", type, data, note? } |
| Who | A role whose author list grants the type (option values: the value's level) |
| Preconditions | The type is known; validate(db, type, data, null) passes |
| Effect | New record, next id of its type; version 1, Created, active, author = actor, note if given |
| Audit entry | action: "create", v: 1, before: null, after: the data, reason: the note if given |
| Refusals | "Unknown record type."; "As <roles> you can't create a <type>." (for option values followed by " at this level"); any validation message of section 5 |
4.3 edit
| Command | { kind: "edit", id, data, note? } |
| Who | A role with author permission for the record; on a Created version only its author |
| Preconditions | Latest version's lifecycle is active; an option value keeps its definition, level and target; the data differs; validate passes; on an approved version a reason of at least 3 characters |
| Effect on Created | The same version gets the new data and updatedAt; version number unchanged |
| Effect on Approved | A new version v+1: Confirmed, active, author and confirmedBy = actor, note = reason. The approved version stays in effect until v+1 is approved |
| Audit entry | action: "edit", v: the version changed or created, before: previous data, after: new data, reason |
| Refusals | "As <roles> you can't edit a <type>."; "<id> is being retired or retired and can't be edited." or "<id> is being removed and can't be edited."; "An option value's option, level and target can't change. Remove it and set a new one."; "Nothing changed."; "Only <name>, who wrote <id> v<n>, can edit it while it is Created."; "<id> v<n> is Confirmed and locked. Step it back to Created to change it."; "Changing an approved record needs a reason." |
4.4 confirm
| Command | { kind: "confirm", id } |
| Who | The author of the version |
| Preconditions | State is Created; validate passes again |
| Effect | State Confirmed; confirmedBy, confirmedAt set |
| Audit entry | action: "confirm", before: { state: "created" }, after: { state: "confirmed" } |
| Refusals | "<id> v<n> is <state>, not Created."; "Only the author of <id> v<n> can confirm it."; "<id> v<n> can't be confirmed: <validation message>" |
4.5 approve (the electronic signature)
| Command | { kind: "approve", id, meaning } |
| Who | A role whose approve list grants the type or level, and who is neither the version's author nor its confirmer |
| Preconditions | State is Confirmed; meaning is not blank; for an active version validate passes again; for a retire or remove version no live record still refers to the record |
| Effect | State Approved; approvedBy, approvedAt, meaning set. The version is now the effective one; an earlier approved version becomes superseded |
| Audit entry | action: "approve", before: { state: "confirmed" }, after: { state: "approved", meaning } |
| Refusals | "<id> v<n> is <state>; only a Confirmed version can be approved."; "As <roles> you can't approve a <type>."; "Four eyes: you wrote <id> v<n>, so someone else has to approve it."; "State what your signature means."; "<id> v<n> can't be approved any more: <validation message>"; the reference message of section 4.8 |
| Server rule | With OpenID Connect sign-in the server refuses an approve when the sign-in is older than the configured age, before the engine is called (section 9.3) |
4.6 step-back
| Command | { kind: "step-back", id, reason } |
| Who | From Confirmed: the author or an approver. From Approved: an approver |
| Preconditions | Reason of at least 3 characters; state is not Created |
| Effect from Confirmed | State Created; confirmedBy, confirmedAt cleared |
| Effect from Approved | State Confirmed; approvedBy, approvedAt, meaning cleared (they remain in the audit entries). An imported version, which has no confirmer, gets its importer as confirmedBy |
| Audit entry | action: "step-back", before: { state: "approved", approvedBy } or { state: "confirmed" }, after: { state: "confirmed" } or { state: "created" }, reason |
| Refusals | "Stepping back needs a reason."; "<id> v<n> is already Created, the first stage."; "As <roles> you can't revoke an approval of a <type>."; "Only the author or an approver can step <id> v<n> back." |
4.7 retire and remove
| Command | { kind: "retire", id, reason }, { kind: "remove", id, reason } |
| Who | Record approved at some time: a role with author permission. Record never approved (remove only): its author or an approver |
| Preconditions | Reason of at least 3 characters; the latest version is Approved (unless never approved); not already in that lifecycle; retire only from active; no live record refers to it |
| Effect, record approved before | New version v+1: Confirmed, lifecycle retired or removed, same data, author and confirmedBy = actor. It takes effect when someone else approves it |
| Effect, record never approved | The latest version's lifecycle becomes removed at once; no approval |
| Audit entry | action: "retire" or "remove", before: { lifecycle }, after: { lifecycle }, reason |
| Refusals | "To retire a record you need to give a reason." (or "remove"); "<id> was never approved, so there is nothing to retire. Remove it instead."; "Only the author or an approver can remove <id>."; "As <roles> you can't retire a <type>."; "<id> v<n> is still open. Approve it or step it back before you retire <id>."; "<id> is already retired."; "<id> is not active."; the reference message below |
4.8 Reference protection (liveChildren, childBlock)
A record cannot be retired or removed, and a pending retire or remove cannot be approved, while a live record of a
configured type refers to it through any reference field: "<name> still has <n> active record(s) that refer(s) to it (<ids>). Retire or move them before you retire it."
Option values are not counted as referring records: a record with option values set for it, and an option definition with values, can be retired or removed. Such values stop having any effect because resolution only considers live definitions and live targets (section 6).
4.9 import
| Command | { kind: "import", type, data, origin: { system, id, batch, approvedBy?, approvedAt? } } |
| Who | A role whose author list contains import. The web interface refuses the command; only the command-line tool issues it |
| Preconditions | The type is known; origin has system, id and batch; no record of that type already carries the same origin system and id; validate passes (the same checks as for data entered by hand) |
| Effect | New record; version 1 Approved, active, author = importer, approvedAt = time of import, no approvedBy, meaning = "Migrated from <system> as approved there (batch <batch>).", note = "Imported from <system>, id <id>.", origin stored |
| Audit entry | action: "import", v: 1, before: null, after: { data, origin }, reason: the note |
| Refusals | "As <roles> you can't import records. A role needs the "import" permission."; "Unknown record type."; "An imported record needs its origin: system, id and batch."; "<system> <id> was already imported."; validation messages |
4.10 configure
| Command | { kind: "configure", config, reason, reviewer?: { id, name } } |
| Who | First configuration: whoever initialises the store (the server and the tool use the actor system, name System). Later: a role whose author list contains configure. Command-line tool only |
| Preconditions | checkConfig reports no problem; reason of at least 3 characters. For a change: the document differs from the current one; a reviewer with id and name, whose id differs from the actor's; configChangeError passes |
configChangeError |
A record type that still has records not removed cannot be dropped; a type that ever had records keeps its prefix; a field of a type with records keeps its type and, for references, its target type |
| Effect | The configuration is replaced; revision counter + 1. It applies from this entry on; earlier entries replay under the configuration that was in effect when they were written |
| Audit entry | action: "configure", recordId: "CONFIG", recordType: "config", v: new revision, before: { hash } of the old document or null, after: { hash, id, name }, reason; command holds the complete configuration document and the reviewer |
| Refusals | "The configuration is not valid: <problems>"; "Setting the configuration needs a reason."; "As <roles> you can't change the configuration. A role needs the "configure" permission."; "The configuration is unchanged."; "A configuration change needs a reviewer: a second person."; "Four eyes: the reviewer must be someone other than you."; the configChangeError messages |
The reviewer is named in the command by the person running the tool. The engine checks only that the reviewer's id differs from the actor's; the reviewer does not sign in. See risk assessment, R-21.
4.11 propose-config, approve-config, reject-config, withdraw-config
The configuration change in two steps, available in the application, over HTTP and from the command-line tool. The
state holds at most one waiting proposal (Db.proposal: the document, its hash, the hash of the configuration it was
written against, the reason, the proposer, the time and the entry number); it is rebuilt by replay.
| Command | Who | Preconditions | Effect and audit entry |
|---|---|---|---|
{ kind: "propose-config", config, reason } |
configure permission |
No proposal waits; checkConfig reports no problem; reason of at least 3 characters; the document differs from the current one; configImpact reports no blocker |
The proposal is stored; the configuration is unchanged. after: { proposed: <hash>, id, name }, reason |
{ kind: "approve-config", hash, meaning } |
configure permission; not the proposer |
A proposal waits and hash is its hash; a meaning is given; the configuration in effect is the one the proposal was written against; configImpact still reports no blocker. Over HTTP with entra or oidc: a recent sign-in (section 9.3) |
The proposal becomes the configuration; revision + 1; no proposal waits. before: { hash }, after: { hash, id, name, proposedBy, proposedIn, reason, meaning } |
{ kind: "reject-config", hash, reason } |
configure permission; not the proposer |
A proposal waits and hash is its hash; reason of at least 3 characters |
No proposal waits; the configuration is unchanged. after: { rejected: <hash> }, reason |
{ kind: "withdraw-config", reason? } |
The proposer | A proposal waits | No proposal waits. after: { withdrawn: <hash> } |
All four entries have recordId: "CONFIG", recordType: "config" and v: the revision under discussion.
configImpact(db, next) returns blockers and notices. Blockers (the change is refused): the id of the configuration
changes; no role would hold configure; a record type with records that are not removed, or with option values set
for its records, is dropped; a prefix changes after records existed; a field's type or reference target changes while
the type has records; a field that holds a value in an active record (approved or pending version) is removed; a
value in such a record would fail the field's new rules (choices, range, pattern); a field becomes unique while two
such records share a value; a parent or group reference becomes required while a record has none; a reference to the
own type becomes the parent while records are their own ancestor through it. Notices (accepted): a field becomes
required while records have no value (they stay in effect; conformance reports them until completed); the order of
inheritance of a type changes; a role is dropped. The complete table with messages is in
Configuration.
4.12 Nested records
A parent reference may be optional (a record without it stands at the top level) and may point at its own record type.
For such a type validate refuses a record that names itself as parent, or a parent that has the record among its own
ancestors, counting the parents in approved and in pending versions (isAbove); the check runs on create, edit,
confirm and approve. Revoking an approval (step-back) is refused if the version that would come back into effect puts
the record under one of its descendants. chainFor (section 6) follows the records upwards and therefore covers any
depth; every walk stops at a record it has seen.
5. Data validation (validate, fieldError, checkValue)
Validation runs on create, on edit, again on confirm and again on approve (FR-VAL-07), and on import.
| Rule | Detail | Message pattern |
|---|---|---|
| Unknown field | A key that the record type does not define | "<Type> has no field "<key>"." |
| Required | Empty, null or blank text in a required field | "Enter <label>." |
| Text | text up to 300 characters, longtext up to 4000; optional pattern must match the whole value |
"<Label>: at most <n> characters."; the configured patternHint or "<Label> does not have the expected form." |
| Number, integer | Finite number; integer must be whole; within min and max |
"<Label> must be a number."; "<Label> must be a whole number."; "<Label>: the smallest allowed value is <min>."; "<Label>: the largest allowed value is <max>." |
| Yes/no | true or false |
"<Label>: choose yes or no." |
| Date | YYYY-MM-DD and a real calendar date |
"<Label> must be a date (YYYY-MM-DD)." |
| Selection | One of the configured choices | "<Label>: choose one of <choices>." |
| Reference | The target exists, has the configured type, and is neither removed nor retired | "<Type> <id> does not exist."; "<Type> <id> was removed."; "<Type> <id> is retired." |
| Unique | No other record of the type that is not removed holds the value in its effective or its open version (text compared case-insensitively, trimmed) | "<Label> <value> is already used by <id>." |
6. Option resolution (lib/resolve.ts)
resolveFor(db, id) returns, for a record of a configured type whose effective version is active, the value of every
live option definition:
- Build the chain (
chainFor): the record itself, then, for each of its reference fields with roleparentorgroupin the order the fields appear in the configuration, the chain of the record that field points at (depth first, each record once). References are read from effective versions only. - For each live definition, start with the definition's
default, sourcedefault. - Walk the chain from most specific to least specific. For each option value record set for that definition and that
chain member:
- if it has an open version, report it under
pending(level, record, version, state, proposed value); - if it has no effective version or is not live, skip it;
- if its approved value no longer passes
checkValueagainst the definition, report it underinvalidand skip it; - the first remaining value becomes the result, with
source(the level),sourceId(the record it was set for),recordIdandv(the option value record and version); - every later one is reported under
shadowed.
- if it has an open version, report it under
Only approved, active values of live definitions on live targets are ever applied (FR-OPT-04, FR-OPT-05, FR-OPT-07).
7. Audit entry
One entry per accepted command, built in commit (lib/engine.ts).
| Field | Type | Content |
|---|---|---|
seq |
integer | Position in the trail, from 1, without gaps |
at |
string | Time of the action, ISO 8601 in UTC with milliseconds, from the server's clock (section 9.5) |
actor |
string | The acting person's id |
actorName |
string | Their name at the time |
actorRoles |
string[] | Their role ids at the time |
action |
string | create, edit, confirm, approve, step-back, retire, remove, import, configure |
recordId |
string | The record acted on; CONFIG for the configuration |
recordType |
string | Its type; config for the configuration |
v |
integer | The version the action produced or changed; the revision for a configuration |
before |
any | What was there before (per command, section 4); null if nothing |
after |
any | What is there now (per command, section 4) |
reason |
string, optional | The reason or note given; absent when none was given |
command |
object | The command exactly as it was given, so the entry can be re-run |
auth |
object, optional | How the server identified the person: method (entra, oidc, proxy, demo, cli, system), optional authTime (when they last authenticated), optional issuer (the identity provider; for cli the operating-system account and host) |
prevHash |
string | hash of the previous entry; 64 zeros for entry 1 |
hash |
string | SHA-256, 64 lowercase hexadecimal characters, over all the fields above |
The exact hash computation, what replay checks and how to verify independently are in Data integrity, sections 3 to 6.
8. Functional requirements by group
8.1 Configuration
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-CFG-01 | A configuration document is checked completely before use. | medium | automated test | UR-01 |
| FR-CFG-02 | The first configuration is audit entry #1 and carries the configuration's hash. | medium | automated test | UR-12 |
| FR-CFG-03 | Changing the configuration needs the permission, a reason and a second person. | medium | automated test | UR-12 |
| FR-CFG-04 | A configuration change cannot orphan existing records. | medium | automated test | UR-12 |
| FR-CFG-05 | Without a configuration no record can be created. | medium | automated test | UR-01 |
| FR-CFG-06 | A new configuration applies from its entry on, and the history still replays. | medium | automated test | UR-12 |
| FR-CFG-07 | A person with the configure permission proposes a configuration change with a reason; it is one entry and changes nothing yet. | high | automated test | UR-12 |
| FR-CFG-08 | A proposed configuration takes effect only when a different person with the configure permission approves it. | high | automated test | UR-02, UR-12 |
| FR-CFG-09 | A proposal can be rejected with a reason by a second person or withdrawn by its proposer; neither changes the configuration. | medium | automated test | UR-12 |
| FR-CFG-10 | A proposal that would strand existing data is refused, naming the records, when it is proposed and again when it is approved. | high | automated test | UR-12 |
| FR-CFG-11 | A field may become required while records lack it: they stay in effect, are reported, and must be completed with their next change. | medium | automated test | UR-12 |
| FR-CFG-12 | The waiting proposal is rebuilt by replay, and a trail written before proposals existed replays to the same hashes. | high | automated test | UR-05, UR-12 |
| FR-CFG-13 | Over HTTP one person proposes a configuration change and another approves it; the approval is a signature and needs a recent sign-in. | high | automated test | UR-07, UR-12 |
| FR-HIER-01 | A record type may nest inside itself and a parent may be optional; other cycles between types stay refused. | medium | automated test | UR-01 |
| FR-HIER-02 | No record becomes its own ancestor: on create, edit, approval and when an approval is revoked, pending changes included. | high | automated test | UR-01 |
| FR-HIER-03 | Options are inherited down every level of a nested branch: the nearest ancestor wins, then the levels above, then the default. | high | automated test | UR-08 |
| FR-HIER-04 | A record cannot be retired or removed while records sit under it, at any level of a nested branch. | medium | automated test | UR-09 |
| FR-HIER-05 | A record type that nests inside itself is migrated parents first; a row whose parent comes later in the file is refused, not waited for. | medium | automated test | UR-11 |
checkConfig in lib/config.ts returns every problem of a document in reading order: schema version, slugs, reserved
ids (definition, value, config, import, configure), duplicate ids and prefixes, field keys and types, select
choices, reference targets, at most one parent and one group reference per type (a group must be required; a parent may be
optional, and may point at its own type if it is optional), a name field
that is a required text field, valid regular expressions, no cycle in the parent and group graph other than a parent
reference of a type to itself, role ids, and known
permissions. configure in lib/engine.ts applies the rules of section 4.10. configHash is the SHA-256 of the
canonical JSON of the document; it is what countersign status, /api/health and the audit entry show. Without a
configuration every other command is refused.
8.2 Workflow and four eyes
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-WF-01 | A new record starts as version 1, Created, with the next id of its type. | high | automated test | UR-02 |
| FR-WF-02 | Only its author can edit a Created version, and the version number stays. | high | automated test | UR-02 |
| FR-WF-03 | Only its author can confirm a version. | high | automated test | UR-02 |
| FR-WF-04 | Four eyes: nobody approves a version they wrote or confirmed. | high | automated test | UR-02 |
| FR-WF-05 | Approval needs the approve permission for the record type. | high | automated test | UR-02 |
| FR-WF-06 | Only a Confirmed version can be approved, and the signature states its meaning. | high | automated test | UR-02 |
| FR-WF-07 | A Confirmed version is locked against edits. | high | automated test | UR-02 |
| FR-WF-08 | Step back moves one stage: Confirmed to Created, Approved to Confirmed. | high | automated test | UR-02 |
| FR-WF-09 | Step back, retire, remove and changing an approved record need a reason. | high | automated test | UR-02 |
Implemented in act (cases edit, confirm, approve, step-back) and create in lib/engine.ts; see sections 3
and 4. The four-eyes rule is the single condition last.author === actor.id || last.confirmedBy === actor.id in the
approve case and compares person ids as delivered by the identity provider. The minimum reason length is the constant
REASON_MIN (3 characters after trimming).
8.3 Versions
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-VER-01 | Editing an approved record opens a new Confirmed version; the approved one stays in effect. | high | automated test | UR-03 |
| FR-VER-02 | Once the new version is approved it is in effect and the old one is kept as superseded. | high | automated test | UR-03 |
| FR-VER-03 | Revoking an approval brings the previous approved version back, or leaves none in effect. | high | automated test | UR-03 |
Implemented in the edit case (new version on an approved record) and by effective, openVersion and
versionLabel. Revoking an approval (step-back from Approved) makes the previous approved version effective again
because effective picks the newest approved version; if there is none, nothing is in effect.
8.4 Data validation
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-VAL-01 | Required fields must be filled. | medium | automated test | UR-01 |
| FR-VAL-02 | Values must have the field's type. | medium | automated test | UR-01 |
| FR-VAL-03 | Numbers stay inside their range and text matches its pattern. | medium | automated test | UR-01 |
| FR-VAL-04 | Unique fields are unique among records that are not removed, pending values included. | medium | automated test | UR-01 |
| FR-VAL-05 | References must point at live records of the right type. | medium | automated test | UR-01 |
| FR-VAL-06 | Fields the configuration does not know are refused. | medium | automated test | UR-01 |
| FR-VAL-07 | Data is checked again when it is confirmed and when it is approved. | medium | automated test | UR-01 |
Implemented in validate, fieldError and refError in lib/engine.ts; see section 5.
8.5 Options and inheritance
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-OPT-01 | An option definition must be consistent: type, choices, range, default. | medium | automated test | UR-08 |
| FR-OPT-02 | Option names are unique. | medium | automated test | UR-08 |
| FR-OPT-03 | An option value fits its definition, is set once per record, and keeps its target. | medium | automated test | UR-08 |
| FR-OPT-04 | The effective value is the most specific approved one, with its source. | medium | automated test | UR-08 |
| FR-OPT-05 | Only approved, active values count; pending changes are reported beside them. | medium | automated test | UR-08 |
| FR-OPT-06 | A definition cannot change in a way that strands values in use. | medium | automated test | UR-08 |
| FR-OPT-07 | An approved value that no longer fits its definition is skipped and reported, not applied. | medium | automated test | UR-08 |
Definitions and values are validated in validate and checkValue; usageError prevents a definition from changing
its type, removing a choice in use or narrowing a range below values in use ("These choices are in use and can't be removed or renamed: …", "These values in use fall outside the new range: …", "<n> value(s) is/are set for this option, so its type can't change."). Resolution is resolveFor in lib/resolve.ts; see section 6.
8.6 Retire and remove
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-LIFE-01 | Retiring is a new version that takes effect only when someone else approves it. | medium | automated test | UR-09 |
| FR-LIFE-02 | Removing an approved record needs approval; afterwards it accepts no command and keeps its history. | medium | automated test | UR-09 |
| FR-LIFE-03 | A record that was never approved is removed at once, by its author or an approver, and cannot be retired. | medium | automated test | UR-09 |
| FR-LIFE-04 | A record that others refer to cannot be retired or removed. | medium | automated test | UR-09 |
Implemented in the retire/remove case of act, liveChildren and childBlock; see sections 4.7 and 4.8.
8.7 Audit trail
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-AUD-01 | Every command that succeeds writes exactly one complete entry. | high | automated test | UR-04 |
| FR-AUD-02 | A refused command writes nothing and changes nothing. | high | automated test | UR-04 |
| FR-AUD-03 | Entries are in time order: an action dated before the newest entry is refused. | high | automated test | UR-04 |
| FR-AUD-04 | How the person was identified is part of the entry and of its hash. | high | automated test | UR-04 |
commit is the only place that creates an entry and it is reached only when every check has passed. act refuses a
time earlier than the newest entry. The auth object is passed in by the server (App.act to Service.submit) from
the session or the proxy headers, never from the request body, and is part of the hashed content.
8.8 Integrity
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-INT-01 | Every entry carries the hash of the one before it. | high | automated test | UR-05 |
| FR-INT-02 | An entry changed after it was written is found. | high | automated test | UR-05 |
| FR-INT-03 | A forged entry with a freshly computed hash is caught when its command is re-run. | high | automated test | UR-05 |
| FR-INT-04 | A missing, reordered or inserted entry is found. | high | automated test | UR-05 |
| FR-INT-05 | The hash does not depend on the order of keys a store returns. | high | automated test | UR-05 |
| FR-INT-06 | Replaying a whole history reproduces the same state, for every demo domain. | high | automated test | UR-05 |
stable, hashEntry, replay, verifyChain in lib/engine.ts. replay re-runs each stored command as the stored
actor at the stored time under the state built so far and requires the resulting entry's hash to equal the stored hash;
it stops at the first entry that is unreadable, breaks a rule, or differs. Details in
Data integrity.
8.9 Access control
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-ACC-01 | A role writes only the record types and option levels it is given. | high | automated test | UR-06 |
| FR-ACC-02 | A person without a role, or with roles the configuration does not know, can change nothing. | high | automated test | UR-06 |
| FR-ACC-03 | Importing and configuring are separate permissions that "*" does not include. | high | automated test | UR-06 |
canAuthor, canApprove, hasAdmin and granted in lib/config.ts. A person's permissions are the union of the
roles named in their session that the configuration knows; unknown role ids grant nothing. Reading is not restricted by
role: App.entries returns the whole trail to any signed-in person.
8.10 Sign-in and signatures
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-SIG-01 | A session cookie that was altered, re-signed with another key, or has expired is not accepted. | high | automated test | UR-07 |
| FR-SIG-02 | Sign-in is the OpenID Connect code flow with PKCE, state and nonce, and yields a session. | high | automated test | UR-07 |
| FR-SIG-03 | A sign-in answer that fails any check gives no session. | high | automated test | UR-07 |
| FR-SIG-04 | Roles come from the token's claims, through the role map. | high | automated test | UR-07 |
| FR-SIG-05 | A signature needs a recent sign-in: an approval with an old one is refused until the person signs in again. | high | automated test | UR-07 |
| FR-SIG-06 | Nothing is readable or writable without signing in. | high | automated test | UR-07 |
| FR-SIG-07 | Writes are accepted only from the application's own pages. | high | automated test | UR-07 |
| FR-SIG-08 | The web interface cannot import or set a configuration in one step, and takes the actor only from the session. | high | automated test | UR-07 |
| FR-SIG-09 | Behind an authenticating proxy, identity headers count only with the shared secret. | high | automated test | UR-07 |
| FR-SIG-10 | Demo sign-in exists only when it is switched on explicitly. | high | automated test | UR-07 |
See section 9.
8.11 Storage
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-STO-01 | Entries are appended and read back in order, unchanged. | high | automated test | UR-10 |
| FR-STO-02 | An entry that does not continue the chain is refused. | high | automated test | UR-10 |
| FR-STO-03 | The trail survives a restart. | high | automated test | UR-10 |
| FR-STO-04 | The database itself refuses to change or delete entries. | high | automated test | UR-04, UR-10 |
| FR-STO-05 | Two servers writing at once cannot fork the trail. | high | automated test | UR-10 |
See section 10.
8.12 Migration
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-MIG-01 | An imported record arrives approved, marked with where it came from. | medium | automated test | UR-11 |
| FR-MIG-02 | The same source record cannot be imported twice. | medium | automated test | UR-11 |
| FR-MIG-03 | Imported data must pass the same checks as data entered by hand. | medium | automated test | UR-11 |
| FR-MIG-04 | Rows are imported parents first, with references and values converted to the configured types. | medium | automated test | UR-11 |
| FR-MIG-05 | Every source row is accounted for: imported or refused with the reason. | medium | automated test | UR-11 |
| FR-MIG-06 | Running a migration again imports only what is not there yet. | medium | automated test | UR-11 |
| FR-MIG-07 | In review mode migrated records wait as Confirmed for an approver here. | medium | automated test | UR-11 |
| FR-MIG-08 | Source files are read as CSV with quotes, semicolons and line ends handled. | medium | automated test | UR-11 |
| FR-MIG-09 | The data model of the system that is replaced is read from SQL definitions and Prisma schemas, with keys, references and code lists. | medium | automated test | UR-11 |
| FR-MIG-10 | A drafted configuration and mapping are valid, every source table and column gets a stated fate, and every rule-of-thumb choice is listed as a decision. | medium | automated test | UR-11 |
| FR-MIG-11 | Capabilities and settings of the old system are reported with where they were seen and how Countersign stands on them; no setting's value is carried. | medium | automated test | UR-11 |
| FR-MIG-12 | After an import every mapped field of every imported record is compared with its source, and each difference or missing record is reported. | medium | automated test | UR-11 |
| FR-MIG-13 | Each step of the migration assistant is journaled with the checksums of its inputs and outputs in a chain that reveals later changes. | medium | automated test | UR-11 |
| FR-MIG-14 | The assistant exports the old data read-only by recorded queries and rehearses import and verification in a scratch store. | medium | automated test | UR-11 |
runMigration in lib/migrate.ts turns each source row into one command. Record types are ordered so that referenced
types come first; references are translated from old identifiers to new record ids; cells are converted to the
configured field type (number with . or , as decimal separator, dates in five formats, yes/no word lists, value
maps). Every row ends as imported, existing (already imported from that system) or failed with the reason. Mode
approved issues import commands; mode review issues create and confirm so that approvers sign each record
here. The command-line tool always rehearses against a copy of the state first and writes nothing if the rehearsal
refuses a row, unless --allow-partial is given; it writes a Markdown report with source file hashes, reconciliation
totals and a sign-off table, a JSON report and a cross-reference CSV. parseCsv in lib/csv.ts reads RFC 4180 CSV
with comma, semicolon or tab. See Migration.
8.13 Export
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-EXP-01 | The audit trail exports as CSV with one line per entry and no executable cells. | low | automated test | UR-13 |
auditCsv in lib/present.ts writes one line per entry with the columns seq, at_utc, actor_id, actor_name, actor_roles, action, record_id, record_type, record_name, version, change, reason, signature_meaning, auth_method, auth_time, prev_hash, hash. signature_meaning is filled for approvals.
Cells that start with =, +, -, @, tab or carriage return are prefixed with an apostrophe so that a
spreadsheet does not run them as formulas. The CSV is a review aid: it summarises the change in words and does not
contain the full before, after and command; the complete entries are in the JSON and JSON Lines
exports (countersign export --what audit --format json|jsonl), which are the formats to archive and to verify.
8.14 Operation
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-OPS-01 | An empty store is initialised with the configuration as its first entry, once. | medium | automated test | UR-14 |
| FR-OPS-02 | Entries altered in the store are detected on reading, and from then on every change is refused. | medium | automated test | UR-14 |
| FR-OPS-03 | Entries removed or rewritten while the server runs are detected. | medium | automated test | UR-14 |
| FR-OPS-04 | The server stamps entries with its own clock, never earlier than the newest entry. | medium | automated test | UR-14 |
| FR-OPS-05 | Without storage settings the app is the demo; with them, missing secrets stop the server. | medium | automated test | UR-14 |
| FR-OPS-06 | The health endpoint reports store, head hash, configuration and integrity. | medium | automated test | UR-14 |
| FR-OPS-07 | The engine's refusal reaches the browser as a 422 with the reason. | medium | automated test | UR-14 |
See sections 9.5, 10 and 11.
8.15 User interface
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-UI-01 | Every record shows what is in effect, what is pending, and its state, in the tree and on its page. | medium | end-to-end walkthrough | UR-15 |
| FR-UI-02 | The signature panel shows the change, who wrote it, the signer's name and role and the meaning of the signature before it is signed. | medium | end-to-end walkthrough | UR-15 |
| FR-UI-03 | Actions a person may not take are not offered, and the reason is shown; the buttons are decided by the same engine that enforces the rules. | medium | end-to-end walkthrough | UR-15 |
| FR-UI-04 | Every record shows its complete version history and its own audit trail. | medium | end-to-end walkthrough | UR-15 |
| FR-UI-05 | The effective value of every option is shown with its source, what it overrides and what is pending. | medium | end-to-end walkthrough | UR-15 |
| FR-UI-06 | The audit trail can be searched and filtered by action, record type and person, and exported as CSV and JSON. | medium | end-to-end walkthrough | UR-13, UR-15 |
| FR-UI-07 | The integrity page verifies the hash chain in the browser and shows the head hash. | medium | end-to-end walkthrough | UR-15 |
| FR-UI-08 | Reasons are asked for in a dialog that cannot be submitted empty. | medium | end-to-end walkthrough | UR-15 |
| FR-UI-09 | The tree shows nested records to any depth, with records that have no parent at the top; a record's page shows the path above it and the records directly under it. | medium | end-to-end walkthrough | UR-15 |
| FR-UI-10 | The parent picker of a nested record never offers the record itself or a record under it. | medium | end-to-end walkthrough | UR-15 |
| FR-UI-11 | The configuration editor checks the draft as it is edited, shows every problem next to its cause, and shows the changes and their effect on existing records before a change can be proposed. | medium | end-to-end walkthrough | UR-12, UR-15 |
| FR-UI-12 | A proposed configuration change is shown with its changes on the configuration page and in the inbox of those who may decide; its proposer is not offered the approval. | medium | end-to-end walkthrough | UR-12, UR-15 |
| FR-UI-13 | A record that does not meet the configuration in effect is marked with the reason. | medium | end-to-end walkthrough | UR-15 |
The interface asks the engine which actions a person may take by running confirm, approve, step back, retire and remove
as dry runs (allowedActions in lib/present.ts), so for these what is offered cannot disagree with what is
enforced, and the reason shown is the engine's own message. Whether Edit is offered is decided by the same conditions
written out a second time in allowedActions (an edit needs data, so it cannot be a dry run); its wording differs
slightly from the engine's refusal, and the engine remains the authority when the edit is submitted. These
requirements are verified by the end-to-end walkthrough scripts/guide-shots.mjs and by the manual cases of the
OQ protocol, part B. See the User guide.
8.16 Documentation site
| Id | Requirement | Risk | Verification | Meets |
|---|---|---|---|---|
| FR-DOC-01 | The documentation site builds a search index from the same Markdown files it shows: one entry per section of every document, with the document's title, the section's heading, the heading's address on the page and the section's text. | low | automated test | UR-15 |
| FR-DOC-02 | The documentation search finds the sections that contain every word of the query, ranks a match in a heading or title above a match in the text, links each result to its heading and marks the matched words. | low | automated test | UR-15 |
| FR-DOC-03 | Every document page lists its second- and third-level headings as "On this page", each with an address that exists on the page. | low | automated test | UR-15 |
| FR-DOC-04 | A Mermaid block of a Markdown document is shown as a diagram; its source text is in the page and stays readable where the diagram cannot be drawn. | low | automated test | UR-15 |
The documentation the application serves under /docs is rendered from the Markdown files of the repository when the
site is built (server/docs.ts). The search index is one static file made from the same rendering, so a result always
points at a heading that exists; the matching (lib/docs-search.ts) runs in the browser. Search, the list that follows
the reader and the drawing of diagrams need JavaScript; the documents themselves do not.
9. Server behaviour
9.1 Settings (server/settings.ts)
All variables are prefixed COUNTERSIGN_. Without STORAGE the application is the demo.
| Variable | Default | Meaning |
|---|---|---|
STORAGE |
unset (demo) | memory, file, sqlite or postgres |
DATABASE_URL |
falls back to DATABASE_URL |
PostgreSQL connection string; required for postgres |
DATA_FILE |
./data/countersign.sqlite or ./data/countersign.jsonl |
File of the SQLite or file store |
AUTH |
entra |
entra, oidc, proxy or demo |
ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET |
none | Required with entra; the issuer is https://login.microsoftonline.com/<tenant>/v2.0 |
OIDC_ISSUER, OIDC_CLIENT_ID |
none | Required with oidc |
OIDC_CLIENT_SECRET |
none | Optional with oidc |
OIDC_SCOPES |
openid profile email |
Scopes requested |
ID_CLAIM |
oid (entra), sub (oidc) |
Claim used as the person's id |
NAME_CLAIM |
name |
Claim used as the person's name |
ROLE_CLAIM |
roles (entra), groups (oidc) |
Claim holding roles or groups |
ROLE_MAP |
{} |
JSON object: identity-provider value to role id. Values not in the map are used unchanged |
PROXY_USER_HEADER, PROXY_NAME_HEADER, PROXY_ROLES_HEADER |
x-forwarded-user, x-forwarded-preferred-username, x-forwarded-groups |
Headers read with proxy |
PROXY_SECRET |
none | Required with proxy, at least 16 characters; the proxy sends it in X-Countersign-Proxy-Secret |
SESSION_SECRET |
none | Required unless proxy, at least 32 characters; key of the session cookie's HMAC |
SESSION_HOURS |
8 |
Lifetime of a session |
SIGN_MAX_AGE_MINUTES |
15 with entra and oidc, otherwise 0 |
Maximum age of the sign-in for an approval; 0 switches the check off. Enforced only with entra and oidc |
PUBLIC_URL |
derived from the request | The address people use; fixes the redirect address and the origin check |
CONFIG |
none | A configuration file or preset:<id>, applied when the store is empty |
DEMO_DATA |
off | Fills an empty store with a preset's demo history (evaluation only) |
Read directly by the PostgreSQL adapter (server/storage/postgres.ts): COUNTERSIGN_SKIP_MIGRATE=1 (do not run
db/postgres.sql on start), COUNTERSIGN_SCHEMA_FILE (path of that file), COUNTERSIGN_DB_POOL (connections, default
5). Read by the tests only: COUNTERSIGN_TEST_DATABASE_URL.
settingsProblems lists what is missing; while the list is not empty the server answers 503 and serves nothing
(FR-OPS-05). The authoritative operator documentation is Deployment.
9.2 Sign-in and session
| Mechanism | Implementation |
|---|---|
| OpenID Connect | startSignIn, finishSignIn in server/auth/oidc.ts: authorization code flow with PKCE (S256), state and nonce; the flow data is kept for ten minutes in a signed cookie countersign_signin. The ID token is verified with the provider's published keys for signature, issuer, audience and expiry (60 seconds tolerance), then the nonce is compared |
| Identity | actorFromClaims: id from ID_CLAIM (a token without that claim is refused: "The ID token has no "<claim>" claim."), name from NAME_CLAIM (falling back to preferred_username, email, id), roles from ROLE_CLAIM (array or a string split on spaces and commas) mapped through ROLE_MAP, duplicates removed |
| Authentication time | The token's auth_time; if the provider does not send it, the token's issue time iat. After a sign-in requested with fresh=1, a token whose auth_time is more than 5 minutes old is refused: "The identity provider did not ask you to sign in again (its authentication time is old)." This check cannot apply when the provider sends no auth_time |
| Session | seal, readSession in server/auth/session.ts: cookie countersign_session containing actor, auth evidence and expiry, authenticated with HMAC-SHA256; HttpOnly, SameSite=Lax, Secure when the public address is https. Nothing is stored on the server. An altered, differently keyed or expired cookie is ignored (FR-SIG-01) |
| Sign-out | App.logout clears the cookie in that browser. The identity provider's session is not ended |
| Reverse proxy | App.identify: identity headers are accepted only when X-Countersign-Proxy-Secret equals PROXY_SECRET (compared in constant time). No session cookie, no authentication time |
| Demo sign-in | App.demoLogin: a person picked from a preset's list; exists only with AUTH=demo (FR-SIG-10) |
9.3 Write requests (App.act)
Checks in this order; the first that fails answers.
| Order | Check | Answer |
|---|---|---|
| 1 | Server mode and settings complete | 404 in the demo; 503 "The server is not configured completely." |
| 2 | Header X-Countersign-Request: 1 present |
403 "The request is missing the X-Countersign-Request header." |
| 3 | Origin header, if present, equals the server's origin |
403 "The request comes from another site." |
| 4 | A valid session or proxy identity | 401 "Sign in first.", code signin_required |
| 5 | Body at most 256 KiB, no NUL character, valid JSON with a command that has a kind |
413 "The request is too large."; 400 "The request contains a NUL character."; 400 "The request is not valid JSON."; 400 "The command is missing." |
| 6 | The command is not import or configure (a configuration change over HTTP is propose-config followed by approve-config, section 4.11) |
403 ""import" is only available from the command-line tool (see docs/operations.md)."; for configure a message that names the two-step commands |
| 7 | For the signatures approve and approve-config with entra or oidc and a maximum age above 0: the session's authentication time is within the limit |
401 "A signature needs a sign-in from the last <n> minutes. Sign in again, then approve.", code reauth_required, loginUrl ending in /auth/login?fresh=1 |
| 8 | The engine accepts the command (Service.submit) |
200 with the entry; 422 with the engine's message; 503 on an integrity fault; 409 "Too many people are writing at once; try again." after six lost races |
The actor and the auth evidence are taken from the session or the proxy headers only; an actor in the body is ignored
(FR-SIG-08). /auth/login?fresh=1 sends the person to the provider with max_age=0 and prompt=login.
9.4 Read requests
| Endpoint | Access | Content |
|---|---|---|
GET /api/bootstrap |
Anyone | Mode, version, storage and sign-in method, the signed-in person or null, the maximum sign-in age, whether a configuration exists |
GET /api/entries?after=<n> |
Signed-in people (401 otherwise) | All audit entries after number n, the head number, and the fault if any. Not filtered by role |
GET /api/health |
Anyone | Section 11 |
9.5 Service (server/service.ts)
- State: the replayed
Dbin the memory of each server process. Before every read and writesyncasks the store for its head and replays any new entries throughreplay. - Time:
submitstamps the entry with the server's clock, or with the newest entry's time if the clock is behind it (FR-OPS-04). Clients never supply the time. - Concurrency: one operation at a time per process; between processes the store accepts only an entry that continues the chain, and the loser re-reads and retries up to six times (FR-STO-05).
- Integrity fault: set when
replayrejects an entry, when the store holds fewer entries than already read ("the store holds <n> entries, fewer than the <m> already read: entries were removed"), or when the newest entry's hash changed ("the newest entry in the store is not the one read before: the trail was rewritten"). From then on the process serves the state before the fault for reading and refuses every write with 503 "The audit trail failed its integrity check at entry #<n> (<message>). Changes are refused until it has been investigated." The fault is cleared only by restarting the process against a store that verifies (FR-OPS-02, FR-OPS-03). - Scope of the running check: entries are verified when they are first read by a process. An entry altered in the
store after a process has read it is found at the next start of a server or by
countersign verify, not by the running process, unless the alteration changes the number of entries or the newest entry. - Bootstrap: an empty store gets the configuration named by
COUNTERSIGN_CONFIGas entry 1, written by actorsystemwithauth.methodsystem(FR-OPS-01).
10. Storage (server/storage/)
A store keeps audit entries and nothing else (Store interface in types.ts: init, head, read, append,
close). append must refuse an entry whose seq is not head + 1 or whose prevHash is not the head's hash.
| Adapter | Persistence | Append-only enforced by | Several servers | Intended for |
|---|---|---|---|---|
PostgreSQL (postgres.ts, db/postgres.sql) |
Table countersign_entries |
Triggers refuse UPDATE, DELETE and TRUNCATE; an insert trigger refuses a row that does not continue the chain; primary key on seq, unique hash |
Yes | Production |
SQLite (sqlite.ts) |
One file, WAL, synchronous = FULL |
Triggers refuse UPDATE and DELETE and a row that does not continue the chain | No (one machine) | Small single-server installations |
File (file.ts) |
One JSON document per line, appended and synced | The adapter's own check before appending; nothing prevents another program from editing the file | No | Pilots |
Memory (memory.ts) |
None; lost on restart | The adapter's own check | No | Tests and trials |
PostgreSQL schema: columns seq, at, actor, action, record_id, record_type, prev_hash, hash, entry
(the entry as JSON text, exactly as hashed), inserted_at (the database's own clock, default now()). The
application reads only entry (ordered by seq) and, for the head, seq and hash; the other columns are
convenience copies for indexes and queries and are not covered by the hash chain. The view countersign_audit_trail
offers one row per entry for reporting tools; it takes every column from the hashed entry text, not from the
convenience columns, and adds signature_meaning and inserted_at.
Trigger messages: "countersign: the audit trail is append-only (<operation> refused)" and
"countersign: entry <seq> does not continue the chain". The server applies the schema on start unless
COUNTERSIGN_SKIP_MIGRATE=1. See Storage.
The triggers stop the application's database role and ordinary users. They do not stop the table's owner or a superuser, who can disable or drop them; that is what the hash chain and head-hash anchoring are for.
11. Health and command-line tool
GET /api/health (App.health), no sign-in required:
| Situation | HTTP | Body |
|---|---|---|
| Demo | 200 | { status: "ok", mode: "demo", version } |
| Settings incomplete | 503 | status: "misconfigured", problems[] |
| Store not reachable | 503 | status: "store-unreachable", error: "The store could not be reached. See the server log." (the cause is written to the server log only) |
| Integrity fault | 503 | status: "integrity-fault", fault: { seq, message }, plus the fields below for the state before the fault |
| Normal | 200 | status: "ok", mode: "server", version, storage, auth, entries, headHash, headAt, config: { id, name, revision, hash }, fault: null |
Command-line tool (scripts/countersign.ts; npm run cli -- <command> in the standalone repository). It uses the same
COUNTERSIGN_* variables and the same engine as the server.
| Command | Does | Writes to the trail |
|---|---|---|
init [--config <file or preset:id>] [--demo-data] |
Creates the schema and the first configuration in an empty store | Entry 1 (configure by system) |
status |
Prints store, number of entries, head hash, configuration with revision and SHA-256, number of records, integrity; exit code 1 on a fault | No |
verify [--expect-head <hash>] [--expect-entry <n>:<hash>] |
Reads every entry from the store, checks the chain (verifyChain) and re-runs every command (replay); with --expect-head compares the newest entry's hash, with --expect-entry checks that entry n still has the given hash (an anchor taken earlier); exit code 1 on any failure |
No |
config validate <file> |
Checks a configuration document and prints its SHA-256 | No |
config show |
Prints the configuration in effect | No |
config apply <file> --reason … --reviewer-id … --reviewer-name … |
Issues configure |
One entry |
import --mapping <file> --dir <folder> [--mode approved or review] [--dry-run] [--allow-partial] [--report <folder>] |
Rehearses, then imports; writes the reports | One entry per record (approved) or two (review) |
export [--what audit, records or config] [--format json, jsonl or csv] [--out <file>] |
Writes the audit trail, the records with all versions, or the configuration | No |
Commands that write require --actor-id, --actor-name and --actor-roles. The entry records auth.method cli,
the operating-system account and host as auth.issuer, and the time. The identity and roles are stated by the person
running the tool and are not verified against the identity provider; control of who can run the tool with write access
to the store is therefore a procedural and infrastructure control (risk assessment, R-20).