On this page

Validate it · docs/validation/functional-specification.md

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:

  1. Build the chain (chainFor): the record itself, then, for each of its reference fields with role parent or group in 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.
  2. For each live definition, start with the definition's default, source default.
  3. 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 checkValue against the definition, report it under invalid and skip it;
    • the first remaining value becomes the result, with source (the level), sourceId (the record it was set for), recordId and v (the option value record and version);
    • every later one is reported under shadowed.

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 Db in the memory of each server process. Before every read and write sync asks the store for its head and replays any new entries through replay.
  • Time: submit stamps 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 replay rejects 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_CONFIG as entry 1, written by actor system with auth.method system (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).