Configuration reference
A Countersign deployment gets its record types, fields, hierarchy and roles from one JSON document, the domain
configuration. This page describes every property of that document, every rule it must satisfy, how to design one for
your own domain, and how a configuration is put into effect and changed later. The source of truth is lib/types.ts
(the shape) and checkConfig in lib/config.ts (the rules).
Contents
- The document at a glance
- DomainConfig
- EntityType (a record type)
- FieldDef (a field)
- Field types and what the engine checks
- References: parent, group and link
- Options and how values are inherited
- Roles and permissions
- Validation rules of checkConfig
- Design your own domain: a walkthrough
- How a configuration becomes active
- Changing the configuration later
- What is not configurable
The document at a glance
{
"schema": 1,
"id": "fleet",
"name": "Fleet configuration",
"description": "Vehicles by region and depot, grouped by vehicle class.",
"optionGroups": ["Maintenance", "Safety", "Operations", "Telematics"],
"entities": [
{
"id": "region",
"label": "Region",
"plural": "Regions",
"prefix": "RG",
"nameField": "name",
"summary": ["code"],
"fields": [
{ "key": "name", "label": "Name", "type": "text", "required": true, "unique": true },
{ "key": "code", "label": "Code", "type": "text", "required": true, "unique": true, "pattern": "[A-Z]{2,3}" }
]
}
],
"roles": [
{ "id": "qa", "name": "Quality Assurance", "summary": "Approves any record; creates nothing", "author": [], "approve": ["*"] }
]
}
Complete examples:
| Where | Domain | Hierarchy |
|---|---|---|
lib/presets/fleet.ts |
Vehicle fleet | Region › Depot › Vehicle, grouped by Vehicle class |
lib/presets/property.ts |
Housing portfolio | City › Estate › Home, grouped by Tenure |
lib/presets/clinic.ts |
Hospital care settings (synthetic) | Hospital › Ward › Patient, grouped by Care pathway |
lib/presets/org.ts |
Organisation chart | Unit › Unit › … to any depth, with Positions under a unit, grouped by Job family |
examples/config/lab-equipment.json |
Laboratory equipment | Site › Laboratory › Instrument, grouped by Instrument class |
The four presets are TypeScript because they also carry demo data; npm run cli -- config show prints the
configuration of a store as JSON (see Operations).
Besides the record types you configure, two are built in and always present: option definitions (definition, ids
OD-0001…) and option values (value, ids OV-0001…). They are described under
Options.
DomainConfig
| Property | Type | Required | Meaning |
|---|---|---|---|
schema |
number | yes | Format version of the document. Must be 1. |
id |
string | yes | Identifier of the domain: a lowercase slug (a letter, then up to 39 letters, digits or hyphens), such as fleet. Shown by status and /api/health. |
name |
string | yes | Display name, such as Fleet configuration. Used as the name of the configuration in the audit trail. |
description |
string | no | One sentence on what the domain covers. |
entities |
EntityType[] | yes, at least one | The record types. Their order is the order in which the application lists them. |
roles |
RoleDef[] | yes, at least one | What each role may write and approve. |
optionGroups |
string[] | no | Suggested groups for option definitions. A suggestion only: a definition may use any group name. |
EntityType (a record type)
| Property | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | Lowercase slug, unique among the record types. Used in permissions (vehicle, value@vehicle), in commands and in the audit trail (recordType). The ids definition, value, config, import and configure are reserved. |
label |
string | yes | Singular name shown to people: Vehicle. Used in messages (As Fleet Coordinator you can't create a region.). |
plural |
string | yes | Plural name: Vehicles. |
prefix |
string | yes | Two to five capital letters. Record ids are <prefix>-0001, <prefix>-0002, … (four digits, growing beyond 9999). Must be unique; OD and OV are taken by the built-in types. |
nameField |
string | yes | Key of the field shown as the record's name. That field must be of type text and required. |
summary |
string[] | no | Keys of fields shown next to the id under the record's title. Each must be one of the type's fields. |
description |
string | no | Explains the record type to the people who fill it in. |
fields |
FieldDef[] | yes, at least one | The fields, in the order they are shown. The order of the reference fields also decides the inheritance order (see below). |
FieldDef (a field)
| Property | Type | Applies to | Meaning |
|---|---|---|---|
key |
string | all | Name of the field in the record's data: a letter, then up to 39 letters, digits or underscores. Unique within the record type. |
label |
string | all | Shown to people and used in messages. Required. |
type |
string | all | One of text, longtext, number, integer, boolean, date, select, ref. |
required |
boolean | all | The field may not be empty. A group reference must be required; a parent reference may be optional (the record can then stand at the top level). Empty means missing, null, "" or only white space. false is a value, so a required boolean accepts false. |
unique |
boolean | all | No two records of the type may share the value. Removed records do not count; retired records and versions waiting for approval do. Text is compared without regard to case and surrounding spaces. |
choices |
string[] | select |
The allowed values. Required for a select, non-empty, no duplicates. |
ref |
string | ref |
The id of the record type the field points at. Required for a ref. |
role |
string | ref |
parent, group or link. A ref without a role behaves as link. A parent may point at the record's own type: the type then nests inside itself. |
min, max |
number | number, integer |
Smallest and largest allowed value. min may not exceed max. |
pattern |
string | text, longtext |
A regular expression (JavaScript syntax) that the whole value must match; the engine wraps it as ^(?:pattern)$. |
patternHint |
string | text, longtext |
The message shown when the value does not match. Without it the message is <Label> does not have the expected form. |
hint |
string | all | Help text shown under the input. |
Field types and what the engine checks
Every create, edit, confirm and approval runs the record's data through validate in lib/engine.ts. A record may
only carry keys that are fields of its type (Vehicle has no field "colour".). An empty optional field is accepted; an
empty required field is refused with Enter <label in lower case>.
| Type | Stored as | Checks | Message when refused |
|---|---|---|---|
text |
string | At most 300 characters; pattern if given. |
<Label>: at most 300 characters. / the patternHint |
longtext |
string | At most 4000 characters; pattern if given. |
<Label>: at most 4000 characters. |
number |
number | A finite number; min and max if given. |
<Label> must be a number. / <Label>: the smallest allowed value is <min>. / <Label>: the largest allowed value is <max>. |
integer |
number | As number, and a whole number. |
<Label> must be a whole number. |
boolean |
true / false |
Exactly true or false. |
<Label>: choose yes or no. |
date |
string YYYY-MM-DD |
A real calendar date in that form (2026-02-30 is refused). No time, no time zone. |
<Label> must be a date (YYYY-MM-DD). |
select |
string | One of choices, exactly as written. |
<Label>: choose one of <choices>. |
ref |
string (a record id) | A record of the type named in ref that exists and is neither removed nor retired. It need not be approved yet. |
<Type label> <id> does not exist. / … was removed. / … is retired. |
After the single fields, uniqueness is checked: <Label> <value> is already used by <record id>.
Numbers are JSON numbers: 12, 12.5. Send 12, not "12".
References: parent, group and link
A ref field points at another record. Its role says what the reference means:
| Role | Shapes the tree | Passes options on | How many per record type | Must be required | May point at its own type |
|---|---|---|---|---|---|
parent |
Yes: the record sits under the record it points at. | Yes | At most one | No: without one the record stands at the top level | Yes: the type nests to any depth (and must then be optional) |
group |
No: a grouping across parents (a vehicle class, a care pathway). | Yes | At most one | Yes | No |
link (or no role) |
No | No | Any number | No | Yes |
Consequences:
- The tree. A record with a
parentreference sits under the record it points at; a record without one (its type has no parent reference, or an optional one that is left empty) stands at the top level. In the fleet preset,depot.regionIdandvehicle.depotIdare required parent references, which gives Region › Depot › Vehicle, three levels in every branch.classhas no parent, so vehicle classes are a separate list. Branches of different depths are described below. - Inheritance. Options set for a record apply to everything that reaches it through parent and group references.
The order in which a record's
parentandgroupfields are declared is the order of precedence (next section). - No cycles between record types. The parent and group references of all record types together must not form a
cycle, with one exception: a
parentreference may point at its own record type. Alinkmay point anywhere, including at the record's own type (for example "replaces" pointing at another instrument). - No cycles between records. Where a type nests inside itself the engine refuses, on every create, edit, approval
and revoked approval, a record becoming its own ancestor:
Plant Hamburg (UN-0003) is under Operations (UN-0002), so UN-0002 can't be placed under it: a record can't be its own ancestor.A parent that is only proposed (waiting for approval) counts as well, so two pending moves cannot close a loop between them. - Why a group stays required. Every record is filed under exactly one group in the tree and resolves its options through it; an optional group would leave records without a place in that grouping. If some records have no natural group, give them an explicit one ("Unclassified").
- Referential integrity. A record cannot be retired or removed while active records still refer to it through any
reference field, links included:
Harbour Depot still has 5 active records that refer to it (VH-0001, VH-0010, VH-0019, …). Retire or move them before you retire it.
From reference fields to the tree
The fleet preset as a picture. On the left are its record types and the reference fields they declare; on the right is what those fields make of four records of the demo data: the parent references build the tree, the group reference ties a vehicle to a class that sits outside the tree, and a link would draw nothing at all.
flowchart LR
accTitle: How the reference fields of the fleet preset become the tree of records
subgraph config["Configuration: record types and their ref fields"]
direction TB
tv["vehicle"] -- "depotId<br/>role: parent" --> td["depot"]
td -- "regionId<br/>role: parent" --> tr["region"]
tv -. "classId<br/>role: group" .-> tc["class"]
end
subgraph records["Records: the tree people see"]
direction TB
rr["Region RG-0002<br/>West"] --> rd["Depot DP-0003<br/>Ruhr Depot"]
rd --> rv["Vehicle VH-0012<br/>BO-LL 701"]
rv -. "grouped by" .-> rc["Vehicle class VC-0004<br/>Light truck"]
end
config == "applied to the records" ==> records
In the configuration the arrows point from a record type to the type it refers to; in the tree the record that is
referred to through parent is drawn above the records that refer to it.
Hierarchies of different depths
With required parents between different record types every branch has the same depth. Two things lift that:
- An optional parent (
"role": "parent"without"required": true): a record of that type sits under a parent or, with the field left empty, at the top level. Branches can end early or start late. - A type that nests inside itself (a
parentreference whoserefis the type's own id): Location › Location › Location, an organisation chart, a bill of materials. The depth is whatever the records say. Such a reference must be optional, because the first record needs a place to stand.
The organisation chart preset (lib/presets/org.ts) uses both:
{ "id": "unit", "label": "Unit", "plural": "Units", "prefix": "UN", "nameField": "name",
"fields": [
{ "key": "name", "label": "Name", "type": "text", "required": true },
{ "key": "kind", "label": "Kind", "type": "select", "required": true,
"choices": ["Legal entity", "Division", "Site", "Department", "Team"] },
{ "key": "parentId", "label": "Part of", "type": "ref", "ref": "unit", "role": "parent" }
] },
{ "id": "position", "label": "Position", "plural": "Positions", "prefix": "PS", "nameField": "title",
"fields": [
{ "key": "title", "label": "Title", "type": "text", "required": true },
{ "key": "familyId", "label": "Job family", "type": "ref", "ref": "family", "role": "group", "required": true },
{ "key": "unitId", "label": "Unit", "type": "ref", "ref": "unit", "role": "parent", "required": true }
] }
Its demo records form branches of five, three and two levels, and a second unit at the top:
flowchart TB
accTitle: Units nested to different depths in the organisation chart preset, with positions at several levels
g["Unit UN-0001<br/>Northwind Group"] --> ops["Unit UN-0002<br/>Operations"]
g --> q["Unit UN-0014<br/>Quality"]
ops --> plant["Unit UN-0003<br/>Plant Hamburg"]
ops --> log["Unit UN-0008<br/>Logistics"]
plant --> asm["Unit UN-0004<br/>Assembly"]
asm --> l1["Unit UN-0005<br/>Line 1"]
l1 --> p1(["Position PS-0005<br/>Shift Lead Line 1"])
q --> p2(["Position PS-0026<br/>Head of Quality"])
g --> p0(["Position PS-0001<br/>Chief Executive"])
f["Unit UN-0015<br/>Northwind Foundation"] --> go["Unit UN-0016<br/>Grants Office"]
What follows from it:
| Where | Behaviour |
|---|---|
| Tree | Records without a parent stand at the top of their type's section; every record shows what is under it, to any depth. |
| Record page | The path lists everything above the record; a card lists what sits directly under it. |
| Forms | The parent picker of a nesting type shows every possible place by its full path, and never the record itself or a record under it. |
| Options | Inherited down every level, nearest first (next section). A value is set "at the unit level" for one unit and applies to everything under that unit. |
| Permissions | unit and value@unit cover units at every depth. There is no permission per depth. |
| Retire and remove | Refused while active records sit under the record, at any level. |
| Migration | Rows are imported in file order, so a file of a nesting type lists parents before their children; a row whose parent comes later is refused with … was not imported and is picked up by the next run. |
The same works without self-nesting: make depot.regionId optional and a depot can exist without a region, at the top
of the tree.
Options and how values are inherited
Fields describe one record. Options are settings that can be set at any level of the hierarchy and are inherited
downwards: a service interval that is 30,000 km by default, 15,000 km for all light trucks, and 10,000 km for one
particular vehicle. Options are records, not configuration: people with the definition permission create option
definitions in the application, and they go through the same approval as everything else.
An option definition has:
| Property | Meaning |
|---|---|
name |
Unique among the definitions (case-insensitive). |
group |
A heading to file it under; optionGroups of the configuration suggests names. |
kind |
boolean, number, text (at most 300 characters) or selection. |
default |
The value where nothing more specific is set. Must be valid for the kind. |
choices |
For selection only: at least two, no duplicates. Other kinds must have an empty list. |
unit |
For number: shown after the value, such as km. |
min, max |
For number: the allowed range, or null. |
description |
Free text. |
A definition that is in use cannot be changed in a way that strands its values: its kind cannot change, a choice in use cannot be removed or renamed, and the range cannot be narrowed below a value in use.
An option value sets one definition for one record: definitionId, level (the record type of the target),
targetId and value. There can be only one value per definition and target, and the three identifying properties
cannot be edited afterwards (remove the value and set a new one).
Resolution order
The effective value of an option for a record is found by walking the record's chain, most specific first
(chainFor in lib/resolve.ts):
- the record itself;
- then, for each of its
parentandgroupreference fields in the order the fields are declared, the chain of the record that field points at; - finally the definition's default.
The chain follows the records, not the record types. Where a type nests inside itself the chain is as long as the branch is deep, nearest ancestor first; where an optional parent is empty it ends there. For the position "Shift Lead Line 1" of the organisation chart (the job family is declared before the unit):
position Shift Lead Line 1 > family Production > unit Line 1 > unit Assembly > unit Plant Hamburg > unit Operations > unit Northwind Group
Its approval limit is 5000 EUR, set for Plant Hamburg; the 2500 EUR of Operations and the 1000 EUR of the group are listed as overridden, and the default of 500 EUR is not reached.
The first approved, active value found on the chain wins. Values further along the chain are reported as overridden ("shadowed"), not lost. Only approved versions count: a value waiting for approval is shown next to the value it would change and has no effect until someone approves it. An approved value that no longer fits its definition is skipped and reported, not applied.
In the fleet preset the vehicle declares its class before its depot:
{ "key": "classId", "label": "Vehicle class", "type": "ref", "ref": "class", "role": "group", "required": true },
{ "key": "depotId", "label": "Depot", "type": "ref", "ref": "depot", "role": "parent", "required": true }
so the chain of a vehicle is Vehicle › Vehicle class › Depot › Region › default. Put the depot field first and the depot would take precedence over the class.
Worked example
Vehicle VH-0012 (plate BO-LL 701) of the fleet demo data is a Light truck (VC-0004) at the Ruhr Depot (DP-0003)
in the region West (RG-0002). Its chain is
vehicle VH-0012 BO-LL 701 > class VC-0004 Light truck > depot DP-0003 Ruhr Depot > region RG-0002 West
and its options resolve as follows (output of resolveFor on the demo data):
| Option | Default | Effective value | Comes from | Overridden |
|---|---|---|---|---|
| Service interval | 30000 km | 15000 km | class Light truck (OV-0012) |
|
| Inspection interval | 12 months | 6 months | class Light truck (OV-0014) |
|
| Wash programme | Monthly | Monthly | default | |
| Winter tyres required | No | No | default | |
| Max daily driving | 9 h | 8 h | class Light truck (OV-0018) |
|
| Driver licence class | B | C1 | class Light truck (OV-0013) |
|
| Private use allowed | No | No | default | |
| Fuel card limit | 400 EUR/month | 900 EUR/month | class Light truck (OV-0017) |
|
| Handover note | (empty) | (empty) | default | |
| Telematics tracking | Trips only | Trips only | depot Ruhr Depot (OV-0009) |
region West: Live (OV-0004) |
Telematics tracking shows the order at work: the region West says "Live", the Ruhr Depot says "Trips only", nothing is set for the class or the vehicle, so the depot's value applies and the region's is listed as overridden. Had the Light truck class set a value, it would have won over both, because the class comes before the depot in the chain.
The same resolution as a picture: the chain is walked from the vehicle towards the default, and the first level that has an approved, active value decides.
flowchart LR
accTitle: Resolving the option Telematics tracking for vehicle VH-0012 along its chain
v["1 Vehicle<br/>VH-0012<br/>nothing set"] --> c["2 Vehicle class<br/>Light truck<br/>nothing set"]
c --> d["3 Depot<br/>Ruhr Depot<br/>Trips only<br/>(OV-0009)"]
d --> r["4 Region<br/>West<br/>Live<br/>(OV-0004)"]
r --> f["5 Default<br/>Trips only"]
d == "first value found" ==> e(["Effective value:<br/>Trips only,<br/>from the depot"])
r -. "overridden" .-> e
classDef win fill:#e5f3eb,stroke:#1f6b45,color:#15202b
classDef skipped fill:#f8fafb,stroke:#d5dbe1,color:#5b6876
class d,e win
class v,c,f skipped
Roles and permissions
{ "id": "coordinator", "name": "Fleet Coordinator", "summary": "Vehicles and option values at vehicle level",
"author": ["vehicle", "value@vehicle"], "approve": ["vehicle", "value@vehicle"] }
| Property | Meaning |
|---|---|
id |
Lowercase slug, unique. This is the value the identity provider must deliver (an Entra app role value, a group name mapped with COUNTERSIGN_ROLE_MAP); see Authentication. |
name |
Shown to people and in messages. Required. |
summary |
One line on what the role is for. |
author |
Permissions to create, edit, retire and remove. A list; may be empty. |
approve |
Permissions to approve, and to revoke an approval. A list; may be empty. |
The permission vocabulary:
| Permission | Allowed in | Grants |
|---|---|---|
<type> (an entity type id, such as vehicle) |
author, approve |
Records of that type. |
definition |
author, approve |
Option definitions. |
value@<type> (such as value@depot) |
author, approve |
Option values set at that level, that is, for records of that type. |
value@* |
author, approve |
Option values at every level. |
* |
author, approve |
Every record type, definitions, and values at every level. Never import or configure. |
import |
author only |
Bringing records over as approved in another system (the CLI's import, mode approved). |
configure |
author only |
Changing the configuration: proposing a change, and approving or rejecting someone else's proposal, in the application or with the command-line tool; and the CLI's one-step config apply. |
The roles of the fleet preset as a picture. "Writes" stands for the author list (create, edit, retire, remove),
"approves" for the approve list:
flowchart LR
accTitle: Which role of the fleet preset may write and approve which records
coordinator["Fleet Coordinator"] -- "writes and approves" --> vehicles["Vehicles,<br/>values at vehicle level"]
classowner["Class Owner"] -- "writes and approves" --> classes["Vehicle classes,<br/>values at class level"]
depotmanager["Depot Manager"] -- "writes and approves" --> depots["Depots,<br/>values at depot level"]
regionallead["Regional Lead"] -- "writes and approves" --> regions["Regions,<br/>values at region level"]
steward["Configuration Steward"] -- "writes and approves" --> definitions["Option definitions"]
subgraph all["Every record"]
direction TB
vehicles
classes
depots
regions
definitions
end
qa["Quality Assurance"] -. "approves, writes nothing" .-> all
auditor["Auditor"] -. "reads only" .-> all
owner["System Owner"] -- "import, configure" --> system["Imports and<br/>the configuration"]
Whoever holds a role, the engine adds four eyes on top: of two Fleet Coordinators, one writes a vehicle and the other (or Quality Assurance) approves it.
How the engine uses them:
- A person's permissions are the union of the roles they hold. Role ids the configuration does not know are ignored; a person without any known role can read and nothing else.
- Four eyes is not a permission and cannot be configured away: nobody approves a version they wrote or confirmed,
whatever their roles (
Four eyes: you wrote VH-0048 v1, so someone else has to approve it.). Plan at least two people for everyapprovepermission. - Confirming is for the author of the version only. Stepping a Confirmed version back is for its author or anyone who
may approve it; revoking an approval needs the
approvepermission. - Retiring or removing an approved record needs the
authorpermission and then an approval like any other change. A draft that was never approved can be removed at once by its author or by an approver. - Reading is not governed by roles: every signed-in person can read every record and the whole audit trail (see
Security). A role with two empty lists (the presets'
auditor) is the way to say "read only". - The commands
importandconfigureare never accepted over HTTP; they exist for the command-line tool (see Operations). In the application the configuration is changed withpropose-configandapprove-config, by two different people who both holdconfigure(see below). Give the role to at least two people.
Validation rules of checkConfig
checkConfig returns every problem it finds, in reading order. The first group stops the check, because nothing else
can be judged without it:
| Rule | Message |
|---|---|
| The document is a JSON object. | The configuration is not an object. |
schema is 1. |
"schema" must be 1. |
id is a lowercase slug. |
"id" must be a lowercase slug such as "fleet". |
name is not empty. |
"name" is missing. |
| There is at least one record type. | "entities" needs at least one record type. |
| There is at least one role. | "roles" needs at least one role. |
Record types (each message starts with Record type "<id>"):
| Rule | Message |
|---|---|
| The id is a lowercase slug. | the id must be a lowercase slug. |
The id is not definition, value, config, import or configure. |
this id is reserved. |
| The id is used once. | the id is used twice. |
label and plural are given. |
"label" and "plural" are required. |
prefix is two to five capital letters. |
"prefix" is two to five capital letters. |
The prefix is not OD, OV or another type's. |
prefix <P> is already taken. |
| There is at least one field. | needs at least one field. |
| At most one parent reference. | only one field can be the parent reference. |
| At most one group reference. | only one field can be the group reference. |
nameField names a field. |
"nameField" must name one of its fields. |
The name field is text and required. |
the name field must be a required text field. |
Every summary key is a field. |
summary field "<key>" does not exist. |
| Parent and group references form no cycle between record types. A parent reference to the type itself is not a cycle. | Record type "<id>" is its own ancestor: parent and group references must not form a cycle (only a parent reference may point at its own type). |
Fields (each message starts with Record type "<id>", field "<key>"):
| Rule | Message |
|---|---|
The key starts with a letter and uses letters, digits and _. |
the key must start with a letter and use letters, digits and _. |
| The key is used once in the type. | the key is used twice. |
label is given. |
"label" is required. |
type is one of the eight types. |
unknown type "<type>". |
| A select has a non-empty list of non-empty strings. | a select needs non-empty "choices". |
| A select's choices are distinct. | each choice can appear only once. |
| A ref names its target. | a ref needs "ref", the record type it points at. |
| A ref's role is one of the three. | "role" is parent, group or link. |
| A group reference is required. (A parent reference may be optional.) | a group reference must be required. |
| A parent reference to its own record type is optional. | a parent reference to its own record type must be optional, or no first record could be created. |
min is not larger than max. |
"min" is larger than "max". |
pattern compiles as a regular expression. |
"pattern" is not a valid regular expression. |
| A ref points at a record type of this configuration. | points at unknown record type "<ref>". |
Roles (each message starts with Role "<id>"):
| Rule | Message |
|---|---|
| The id is a lowercase slug. | the id must be a lowercase slug. |
| The id is used once. | the id is used twice. |
name is given. |
"name" is required. |
author and approve are lists. |
"author" must be a list (it may be empty). |
| Every permission is from the vocabulary above and names an existing record type. | unknown permission "<p>" in "<list>". |
And for the document: "optionGroups" must be a list of names.
What the output looks like for a document with several mistakes:
$ npm run cli -- config validate bad.json
countersign: bad.json is not valid:
- Record type "value": this id is reserved.
- Record type "value": "label" and "plural" are required.
- Record type "value": prefix OD is already taken.
- Record type "value", field "p": a group reference must be required.
- Record type "value", field "s": a select needs non-empty "choices".
- Record type "value": the name field must be a required text field.
- Record type "value", field "p": points at unknown record type "nope".
- Role "qa": unknown permission "configure" in "approve".
- Role "qa": unknown permission "value@nope" in "approve".
A list entry of the wrong JSON type (a null in fields, a number in a permission list) is reported as one problem,
with the underlying reason in brackets, instead of the messages above:
$ npm run cli -- config validate bad.json
countersign: bad.json is not valid:
- The configuration is malformed: an entry has the wrong type (Cannot read properties of null (reading 'key')).
Fix the JSON and run it again.
Not checked, so check it yourself: summary on a role is not required by the validator, unknown extra properties are
ignored (a typo such as "requierd": true passes silently), and min/max on a field that is not a number are
accepted and have no effect.
Design your own domain: a walkthrough
This builds examples/config/lab-equipment.json: the register of laboratory
instruments of a company with several sites.
1. Write down the record types and how they nest
Ask what an approval is needed for and where settings are decided.
- Instruments are the records that matter. Each stands in a laboratory, each laboratory belongs to a site. That is the tree: Site › Laboratory › Instrument.
- Calibration rules are mostly decided per kind of instrument (all analytical balances, all pH meters), across sites. That is a grouping: Instrument class.
- Instruments are calibrated by a service provider. Worth a record of its own (its accreditation is reviewed), but nothing is inherited from it: a plain link.
2. Give each type an id, labels and a prefix
{ "id": "instrument", "label": "Instrument", "plural": "Instruments", "prefix": "EQ", "nameField": "tag", ... }
Choose ids and prefixes you can live with: the id of a type appears in permissions and in every audit entry, and the
prefix of a type cannot change once it has records. Here: site/ST, laboratory/LB, instrument-class/IC,
provider/SP, instrument/EQ.
3. Decide what is a field and what is an option
A fact about one record is a field: the serial number, the date of the last calibration. A rule that should be set once for many records and overridden where needed is an option: the calibration interval, whether an as-found reading must be recorded. Options are not part of the configuration document; the Option Steward creates them in the application after it is running. The configuration only suggests their groups:
"optionGroups": ["Calibration", "Maintenance", "Qualification", "Data integrity"]
4. Write the fields
Use the narrowest type that fits, so that the engine can check the data:
{ "key": "tag", "label": "Asset tag", "type": "text", "required": true, "unique": true,
"pattern": "[A-Z]{3}-[0-9]{5}",
"patternHint": "The asset tag is the site code, a hyphen and five digits, such as BSL-00417." },
{ "key": "status", "label": "Status", "type": "select", "required": true,
"choices": ["In qualification", "In service", "Out of tolerance", "Out of service"] },
{ "key": "calibrated", "label": "Subject to calibration", "type": "boolean" },
{ "key": "calibrationDue", "label": "Calibration due", "type": "date" },
{ "key": "rangeMax", "label": "Measuring range to", "type": "number" },
{ "key": "channels", "label": "Channels", "type": "integer", "min": 1, "max": 96 }
The name field (tag) must be a required text field. summary picks the fields shown beside the id:
["manufacturer", "model", "status"].
5. Add the references, in the order of precedence
{ "key": "classId", "label": "Instrument class", "type": "ref", "ref": "instrument-class", "role": "group", "required": true },
{ "key": "laboratoryId", "label": "Laboratory", "type": "ref", "ref": "laboratory", "role": "parent", "required": true },
{ "key": "providerId", "label": "Calibrated by", "type": "ref", "ref": "provider", "role": "link" }
The class comes before the laboratory, so an instrument's options resolve in the order Instrument › Instrument class › Laboratory › Site › default: what is set for analytical balances beats what a laboratory set for everything it owns. If your organisation sees it the other way round, swap the two fields.
6. Write the roles
Start from who signs what, and make sure every approve permission is held by at least two people who are not also
the only authors.
| Role | Authors | Approves |
|---|---|---|
metrologist |
instrument, value@instrument |
instrument, value@instrument |
lab-manager |
laboratory, value@laboratory |
laboratory, value@laboratory, instrument, value@instrument |
class-owner |
instrument-class, value@instrument-class |
the same |
site-quality |
site, provider, value@site |
the same |
steward |
definition |
definition |
qa |
nothing | * |
auditor |
nothing | nothing |
system-owner |
import, configure |
nothing |
Keep import and configure in a role of their own, held by the people who operate the system under change control.
7. Validate
$ npm run cli -- config validate examples/config/lab-equipment.json
examples/config/lab-equipment.json is valid: 5 record types, 8 roles. SHA-256 deedf81469275b0b0924282f745841a969e9720a4d76547620b67870aa2d3760
config validate needs no store and no environment variables. The SHA-256 is the fingerprint of the document (of its
content, not of the file's bytes: key order and white space do not matter). Record it in your change request; the same
value appears in the audit trail and in status once the configuration is in effect.
8. Try it on a scratch store
$ export COUNTERSIGN_STORAGE=sqlite COUNTERSIGN_DATA_FILE=./scratch/lab.sqlite
$ npm run cli -- init --config examples/config/lab-equipment.json
Initialised the sqlite store with 1 entry from examples/config/lab-equipment.json. Head #1 d6c42fbaa8ada24a95b5f5f67fdfa318f9f3b9e0c2c38482cc52794f4722945a
$ npm run cli -- status
store sqlite
entries 1
head hash d6c42fbaa8ada24a95b5f5f67fdfa318f9f3b9e0c2c38482cc52794f4722945a
configuration lab-equipment "Laboratory equipment register", revision 1, SHA-256 deedf81469275b0b0924282f745841a969e9720a4d76547620b67870aa2d3760
records 0 (0 including removed)
integrity ok
(The head hash differs from run to run, because the entry carries the time it was written.) Then start the server on
that store and click through it as each role; see Developer setup. For a
quick trial without an identity provider, use the demo sign-in and name the people to pick from with
COUNTERSIGN_DEMO_USERS; see Authentication.
9. Put the document under change control
Keep the configuration file in version control next to your deployment files, and treat a change to it like a change to the software: review, test on a copy, have it proposed by one person and approved by a second (next sections), keep the fingerprint.
How a configuration becomes active
A store starts empty. The first entry of every audit trail is a configure entry that carries the whole configuration
document. There are two ways to write it:
| Way | When |
|---|---|
npm run cli -- init --config <file> or --config preset:<id> |
Explicitly, before the server starts. Recommended for production. |
The server, on its first request, if COUNTERSIGN_CONFIG is set and the store is empty |
Convenient for evaluation and for containers. |
Either way the entry is written by the actor System with the reason Initial configuration from <source>. After
that, COUNTERSIGN_CONFIG has no effect: the configuration in the store is the one in force, and editing the file
changes nothing until you apply it. If the server runs without COUNTERSIGN_CONFIG on an empty store, it starts,
reports "configured": false on /api/bootstrap, and waits for init.
With --demo-data (or COUNTERSIGN_DEMO_DATA=1) and a preset, the preset's whole demo history is written instead of
the single entry. That is for evaluation only.
Changing the configuration later
The configuration can be changed while the system is in use. A change is always the whole new document, written to the audit trail; there are two ways to make one.
| Way | Steps | Second person | Where |
|---|---|---|---|
| Propose and approve (recommended) | Two commands: propose-config, then approve-config |
Signs in and approves; cannot be the proposer | The application, the HTTP API, or config propose / config approve on the command line |
| Apply in one step | One command: configure |
Named by the operator as reviewer; does not sign in | The command line only (config apply) |
In the application: propose and approve
Everyone whose role has the configure permission finds Edit configuration on the configuration page (see the
user guide for the screens).
sequenceDiagram
accTitle: A configuration change: proposed by one person, approved by a second
actor A as Proposer (configure)
participant E as Engine
actor B as Second person (configure)
A->>E: propose-config (document, reason)
Note over E: checks the document and the records<br/>one entry, proposal stored, revision unchanged
E-->>B: shown on the configuration page and in the inbox
alt approves
B->>E: approve-config (fingerprint, meaning)
Note over E: checks the records again<br/>one entry, new revision in effect
else rejects
B->>E: reject-config (fingerprint, reason)
Note over E: one entry, nothing changes
else the proposer withdraws
A->>E: withdraw-config
Note over E: one entry, nothing changes
end
- Edit. The editor works on a draft of the whole document: record types (add, reorder, remove; label, plural, prefix, name field, summary), fields (add, reorder, remove; type, required, unique, choices, reference target and role, range, pattern and its message, help text), roles (name, summary, what each may write and approve) and option groups. Ids of existing record types and roles and keys of existing fields are fixed: renaming one would be removing it and adding another. The draft can also be edited as JSON, or loaded from a file.
- Checks while editing. Every change runs
checkConfig, and each problem is shown next to the record type, field or role it is about. Once the document is valid, the engine's check against the existing records (next section) runs in the browser on the current state, so a refusal is seen before anything is submitted. - Review and propose. The review lists every difference to the configuration in effect and every consequence for
existing records. With a reason of at least three characters the change is proposed: one audit entry
(
propose-config) that carries the whole document. The configuration in effect does not change. - Decide. The proposal is shown on the configuration page and in the inbox of everyone else who holds
configure. A second person approves it (approve-config: the new revision is in effect from the next action) or rejects it with a reason (reject-config). The proposer can withdraw it (withdraw-config).
What the engine requires:
| Step | Requirement | Message when it is not met |
|---|---|---|
| Propose | The configure permission in the configuration in force. |
As Quality Assurance you can't change the configuration. A role needs the "configure" permission. |
| No other proposal is waiting. | A configuration change proposed by Oskar Lund is waiting. It has to be approved, rejected or withdrawn before another one can be proposed. |
|
| The document is valid and differs from the current one. | The configuration is not valid: … / The configuration is unchanged. |
|
| A reason. | Proposing a configuration change needs a reason. |
|
| It does not contradict existing records. | see the next section | |
| Approve | The configure permission. |
As Quality Assurance you can't approve a configuration change. A role needs the "configure" permission. |
| Not the proposer. | Four eyes: you proposed this configuration change, so someone else has to approve it. |
|
| The fingerprint in the command is that of the waiting proposal (what was reviewed is what is signed). | The proposal you looked at is not the one that is waiting now. Review the current proposal first. |
|
| A meaning of the signature. | State what your signature means. |
|
| The configuration in effect is still the one the proposal was written against. | The configuration in effect changed after this was proposed. Reject it and have it proposed again on top of the current configuration. |
|
| It still does not contradict the records, which may have changed since. | The proposed configuration no longer fits the records: … |
|
On a server: a sign-in from the last COUNTERSIGN_SIGN_MAX_AGE_MINUTES minutes, as for every signature. |
HTTP 401, reauth_required |
|
| Reject | The configure permission, not the proposer, a reason. |
You proposed this change: withdraw it instead of rejecting it. / Rejecting a configuration change needs a reason. |
| Withdraw | The proposer. | Only Oskar Lund, who proposed the change, can withdraw it. Someone else with the "configure" permission can reject it. |
Both people are identified by their own sign-in, and both steps are entries in the audit trail with the fingerprint of the document. In the demo, each domain has two System Owners so that the flow can be tried.
What a change may do to existing records
Countersign never rewrites records when the configuration changes. So that no record in use is stranded, a proposal is
checked against the records that exist (configImpact in lib/engine.ts): when it is proposed, again when it is
approved, and in the editor before either. The rule: no record in use may lose data or hold a value that the new
configuration would refuse. "In use" means active (not retired, not removed), in its approved version and in a
version waiting for approval. A change that fails the rule is refused with a message that names the records; change
those records first (each change approved as usual), then propose again.
| Change | Outcome | When refused, and the message |
|---|---|---|
| New record type, new optional field, new role, new choice, new option group | Accepted | |
| Changed label, plural, description, hint, summary, name field, order of types or fields | Accepted | |
| Wider range, looser or removed pattern, field no longer required or unique | Accepted | |
| Changed permissions of a role; a removed role | Accepted. Takes effect for the next action; people who hold only unknown roles can read and nothing else. | Refused if no role would hold configure any more: No role would hold the "configure" permission, so the configuration could never be changed again. |
| A field becomes required (also: a new required field) | Accepted, records are marked. Records without a value stay in effect as approved; each is marked on its page and listed on the configuration page, and its next version must fill the field in. | Refused for a parent or group reference, which decides where a record stands: Post: "Unit" can't become required while 1 record has none (PO-0001); set it there first. |
| A field is removed | Accepted if no record in use holds a value in it. Empty leftovers in stored data are ignored and dropped with the record's next version. | Field "serial" of "item" still holds a value in 2 records (IT-0001, IT-0002); empty it there first, or keep the field. |
| A choice is removed or renamed | Accepted if no record in use has that choice. | Item: 1 record holds a value for "Kind" that the new rules refuse (IT-0002): Kind: choose one of A. Change those records first. |
| A range is narrowed, a pattern added or tightened | Accepted if every value in use still passes. | the same message, with the rule that fails |
| A field becomes unique | Accepted if no two records in use share a value. | Item: "Name" can't become unique while IT-0001, IT-0002 share the value Crate. |
| A field's type changes | Refused while the record type has records that are not removed. | Field "qty" of "item" has records, so its type can't change from integer to text. |
| A reference points at another record type | Refused while the record type has records that are not removed. | Field "siteId" of "laboratory" has records, so it can't point at another record type. |
| A record type's prefix changes | Refused once the type has ever had a record. | Record type "site" has records, so its prefix ST can't change. |
| A record type is removed | Refused while it has records that are not removed, or option values set for its records. | Record type "site" still has 2 records (ST-0001, ST-0002); remove them before dropping the type. / 1 option value is still set for categories (OV-0001); remove them before dropping the type. |
The hierarchy changes: a reference becomes or stops being parent or group, or their order changes |
Accepted, with a notice: the place of the records in the tree and their effective option values change the moment it is approved. | Refused if a reference to the own type becomes parent while records already run in a circle through it: Unit: "Replaces" can't become the parent reference while 2 records are their own ancestor through it (UN-0001, UN-0002). A cycle between record types is refused by the validator. |
| The configuration's id changes | Refused. | The configuration's id "fleet" can't change: it names this domain for monitoring and sign-in. |
A record type that other types refer to cannot be dropped without changing those references too; the validator says so
(Record type "laboratory", field "siteId": points at unknown record type "site".).
A version that was waiting for approval when the rules changed is checked against the new rules when it is approved; if it no longer passes, step it back, edit it and confirm it again.
From the command line
The same two steps, for operators who work from a file under version control:
$ npm run cli -- config check lab-equipment-v2.json
lab-equipment-v2.json against revision 1: 1 change, 0 reasons to refuse it.
+ Instrument: new field Room (room: text)
$ npm run cli -- config propose lab-equipment-v2.json \
--actor-id oskar --actor-name "Oskar Lund" --actor-roles system-owner \
--reason "CR-2026-041: add the room to instruments"
Proposed as entry #2 (SHA-256 e707fa94…). It takes effect when a second person approves it: in the application, or with "config approve".
$ npm run cli -- config approve --actor-id kai --actor-name "Kai Tanaka" --actor-roles system-owner
Configuration revision 2 is in effect (entry #3, SHA-256 e707fa94…), proposed by Oskar Lund in entry #2.
config check writes nothing: it prints the differences and what the engine would say. config reject --reason … and
config withdraw complete the set. A proposal made on the command line can be approved in the application and the
other way round. On the command line each person is who the operator says they are (see Operations).
In one step. config apply issues the older configure command, which changes the configuration at once and
records a reviewer by name:
$ npm run cli -- config apply lab-equipment-v2.json \
--actor-id oskar --actor-name "Oskar Lund" --actor-roles system-owner \
--reason "CR-2026-041: add the room to instruments" \
--reviewer-id kai --reviewer-name "Kai Tanaka"
Configuration revision 2 is in effect (entry #2, SHA-256 e707fa94555e69db28e2d35761b0702d0d48026ab14a1f1551ab5db223277e2e).
flowchart LR
accTitle: The path of a configuration change: command, audit entry, replay
doc["New configuration<br/>document"] --> cmd["propose-config and<br/>approve-config,<br/>or configure"]
cmd --> check{"The engine<br/>checks"}
check -- "refused" --> refused["A message;<br/>nothing is written"]
check -- "accepted" --> entry["Audit entries<br/>with the whole<br/>document and both<br/>fingerprints"]
entry --> replay["Replay: earlier entries<br/>run under the old<br/>revision, later ones<br/>under the new"]
replay --> effect["The new revision<br/>is in effect from<br/>the next action"]
Requirement of configure |
Message when it is not met |
|---|---|
The document is valid (checkConfig). |
The configuration <file> is not valid: and the list |
The actor holds a role with the configure permission in the configuration currently in force. |
As Quality Assurance you can't change the configuration. A role needs the "configure" permission. |
| A reason of at least three characters. | Setting the configuration needs a reason. |
| A reviewer: a second person, with id and name. | A configuration change needs a reviewer: a second person. |
| The reviewer is not the actor. | Four eyes: the reviewer must be someone other than you. |
| The document differs from the current one. | The configuration is unchanged. |
The reviewer is recorded, not authenticated: the tool trusts the person running it to name the colleague who reviewed
the change. Keep the evidence of that review (the approved change request) in your quality system, and quote its number
in --reason. Prefer the two steps wherever two people can run the tool or use the application.
The configure command itself checks existing records only for what it has always checked (a dropped type with
records, a changed prefix, a changed field type or reference target), because an audit trail written by an earlier
version must replay unchanged. config apply therefore runs the full check of the
previous section itself and refuses a change that fails it; --force
applies it regardless, with the consequences that the table above exists to prevent (records that can no longer be
edited until the configuration is corrected). Use it only under a documented deviation.
Why old entries still verify
Every configure and propose-config entry contains the complete document, and replay applies entries in order: the
proposal waiting for approval is itself part of the replayed state. An entry written under
revision 1 is therefore re-run under revision 1, and one written after the change under revision 2. The fingerprints
before and after are in the entry:
2,2026-11-11T11:20:22.234Z,oskar,Oskar Lund,system-owner,configure,CONFIG,config,Laboratory equipment register,2,"configuration e707fa94555e… in effect, replacing deedf8146927…",CR-2026-041: add the room to instruments,,cli,…
The reviewer of a one-step change is part of the entry's command (JSON and JSONL exports); the CSV export has no
column for it. For a change in two steps the proposer and the approver are the actors of their two entries.
An audit trail written before optional parents, nesting and proposals existed replays to the same hashes: the newer rules only accept configurations and commands that the older engine refused.
What is not configurable
These are properties of the engine. Changing them means changing code, with a requirement and a test (see CONTRIBUTING).
| Fixed | Detail |
|---|---|
| The workflow | Created → Confirmed → Approved, one approval step, by one second person. No multi-stage or parallel approval. |
| Four eyes | Nobody approves a version they wrote or confirmed; no exception by role. |
| Reasons | Editing an approved record, stepping back, retiring, removing and configuring need a reason of at least three characters. Notes, reasons and signature meanings are limited to 2000 characters. |
| The signature meaning | Free text entered by the approver at approval; not a configurable list. |
| Record ids | <prefix>-<number>, numbered per type in order of creation. |
| Field types | The eight listed above. No date-time, multi-select, attachment, computed or conditional field; no validation across fields. |
| Text limits | 300 characters for text and text options, 4000 for longtext. |
| Option kinds | boolean, number, text, selection. |
| Read access | Every signed-in person reads everything. No restriction per record, type or field. |
| The built-in types | Option definitions and option values, with prefixes OD and OV. |
| The audit trail | One entry per command, SHA-256 hash chain, append-only. |
| Language | One set of labels per configuration; the application's own texts are English. |