Migrating from another system
How to move records from the system you use today into Countersign: the approach from inventory to sign-off, the two
import modes, the mapping file, the import command and its reports, what is and is not carried over, and a worked
example with real output. There are also short sections for people who start from spreadsheets and for moving
between Countersign stores.
If you are replacing a whole application and have its repository or database at hand, the migration assistant prepares most of what this page describes: it drafts the configuration and the mapping from the old data model, lists what the old system does that Countersign does not, exports the data and rehearses the import. This page remains the reference for the import itself.
Contents
- The approach
- The two modes
- Source files
- The mapping file
- The import command
- The three report files
- Running it again
- Handling refused rows
- What is not migrated
- Validation considerations
- Worked example
- Coming from spreadsheets
- Moving between Countersign stores
The approach
A migration is a project with a beginning and an end. The steps:
| # | Step | Result |
|---|---|---|
| 1 | Inventory. List what the old system holds: record types, fields, code lists, hierarchies, settings, who approves what, how much history. Decide what moves, what is archived and what is dropped. | A scope statement. |
| 2 | Data mapping. For each record type that moves: source table or sheet → Countersign record type; each column → a field; each code → a choice; each foreign key → a reference. Note every transformation and every field with no home. | A mapping specification, reviewed by the data owners. |
| 3 | Design the configuration. Write the domain configuration so that it can hold the data and express the roles (Configuration). Validate it. | The configuration document. |
| 4 | Export from the old system as CSV or JSON, one file per record type, with the old identifiers. Freeze or note the export time. | Source files, with their checksums. |
| 5 | Write the mapping file from the specification. | mapping.json |
| 6 | Dry run against a test store initialised with the configuration. | A dry-run report: what would be imported, what is refused and why. |
| 7 | Fix the source data, the mapping or the configuration, and repeat until the dry run is clean or every refusal is understood and accepted. | A clean report. |
| 8 | Rehearse the cut-over in a qualified test environment with a full export, including timing. | Evidence that the procedure works. |
| 9 | Cut over. Freeze the old system (read-only), take the final export, run the import against production, verify. | The production report and cross-reference. |
| 10 | Reconcile. Counts per type against the old system, the refused rows, samples compared field by field by the data owners. | A signed reconciliation. |
| 11 | Sign off. The report has a sign-off table for the person who ran it, the person who checked it and quality. | The migration record. |
| 12 | Decommission. Archive the old system's data and audit trail, read-only and retrievable for the retention period; withdraw access. | An archive and a note of where it is. |
Steps 6 to 9 use one command, described below.
The two modes
--mode approved (default) |
--mode review |
|
|---|---|---|
| What arrives | Each record as version 1, already Approved and in effect, marked with its origin. | Each record as version 1, Confirmed, waiting for an approver in Countersign. |
| Audit entries per record | One import entry. |
Two: create and confirm. |
| Who approved | Nobody in Countersign. The version says Migrated from <system> as approved there (batch <batch>). and carries who approved it in the old system and when, as that system recorded it. |
A second person here, one record at a time, with a signature. |
| Permission of the person running it | A role with import. |
The author permission for every record type, definitions and value levels in the files. |
| Origin recorded | origin on the version: system, old id, batch, old approver and date. |
A note on the version: Migrated from <system>, id <old id> (batch <batch>). |
| Running it again | Skips what is already there (by system and old id). | Does not recognise what it created before; see Running it again. |
| Use when | The old system was the approved system of record and its approvals are trusted: you transfer approved data and document that decision. | The source has no reliable approval (spreadsheets, an uncontrolled database), the data needs a fresh review, or the volume is small enough to approve by hand. |
Choosing between them is a quality decision, not a technical one. With approved you rely on the old system's
controls and on the reconciliation; the audit trail states plainly that the records were migrated and not approved
here. With review every record gets a real second pair of eyes at the price of that work, and nothing is in effect
until it is done: options do not resolve from values that are still waiting.
The two can be combined: master data that was approved in the old system with approved, a doubtful part with
review, in separate runs with separate mapping files and batch names.
Source files
One file per record type, plus optionally one for option definitions and one for option values, all in one folder.
| Format | Rules |
|---|---|
CSV (any name not ending in .json) |
The first line holds the column names. Encoding UTF-8, with or without a byte order mark. The delimiter is a comma, unless the header line contains more semicolons (German Excel) or tabs than commas. Cells may be quoted with "; a quote inside a quoted cell is doubled; line breaks inside quoted cells are allowed. Lines end with LF or CRLF. Empty lines are skipped. |
JSON (name ends in .json) |
An array of flat objects, one per record: [{"vehicle_id": "FB-1001", "plate_no": "KI-AB 101"}, …]. Every value is converted to text first, so numbers and booleans are then read by the same rules as CSV cells. null counts as empty. |
In both formats cell values are trimmed, and every row needs the old system's identifier: a value that is unique within its file and will not change, because references between files and repeated runs rely on it.
Files in another encoding (Windows-1252 exports from older tools) must be converted to UTF-8 first; otherwise umlauts and accents arrive damaged without an error.
The mapping file
A JSON document that says which file feeds which record type and how each column becomes a field.
{
"system": "FleetBase 4",
"batch": "MIG-2026-11",
"entities": [
{
"type": "vehicle",
"file": "vehicles.csv",
"id": "vehicle_id",
"approvedBy": "approved_by",
"approvedAt": "approved_on",
"fields": {
"plate": "plate_no",
"year": "model_year",
"fuel": { "column": "fuel", "map": { "D": "Diesel", "P": "Petrol", "E": "Electric", "H": "Hybrid" } },
"inService": { "column": "first_reg", "dateFormat": "DD.MM.YYYY" },
"leased": { "column": "lease_flag", "yes": ["Y"], "value": false },
"classId": "class",
"depotId": "depot"
}
}
],
"definitions": { "file": "options.csv", "id": "key", "fields": { "name": "label", "kind": "datatype", "default": "standard" } },
"values": { "file": "option-values.csv", "definition": "option", "level": "scope", "target": "scope_id", "value": "setting" }
}
Top level
| Property | Required | Meaning |
|---|---|---|
system |
yes | The name of the system the data comes from, as it should appear on every imported record: FleetBase 4. Together with the old id it identifies a record's origin, so use exactly the same name in every run. |
batch |
yes | An identifier for this run, such as the change request number: MIG-2026-11. Recorded on every record and used in the report file names. |
entities |
yes (a list, may be empty) | One entry per record type to import. |
definitions |
no | The file with option definitions. |
values |
no | The file with option values. |
An entry of entities
| Property | Required | Meaning |
|---|---|---|
type |
yes | A record type id of the configuration. |
file |
yes | The source file's name, relative to --dir. |
id |
yes | The column with the record's identifier in the old system. |
fields |
yes | Field key of the configuration → how to obtain its value (next table). Every required field must be listed; fields that are not listed stay empty. Keys the configuration does not have are an error. |
approvedBy |
no | Column with who approved the record in the old system. Recorded as text, as given. |
approvedAt |
no | Column with when. Recorded as text, as given; not parsed. |
The order of the entries does not matter: record types are imported so that everything a type refers to comes first (regions before depots before vehicles). Within a file, rows are imported in file order.
A field mapping
Either a column name as a string ("plate": "plate_no"), or an object:
| Property | Meaning |
|---|---|
column |
The column to read. Naming a column that is not in the file refuses the row: column "x" is not in the file. |
value |
A constant. Used when there is no column, or when the cell is empty. {"value": "In service"} sets every record's field to that; {"column": "lease_flag", "value": false} uses false where the cell is empty. |
map |
Source text → target text, applied to a non-empty cell before anything else: {"D": "Diesel"}. A cell that is not in the map refuses the row: Fuel: no mapping for "H2". The keys are matched exactly. |
dateFormat |
For date fields: the form of the source. |
decimal |
For number fields: the decimal separator of the source, "." (default) or ",". |
yes |
For boolean fields: the texts that mean yes. |
Conversions by field type
| Field type | Conversion of a non-empty cell | Empty cell without value |
|---|---|---|
text, longtext, select |
Taken as it is (after map). A select must then equal one of the choices exactly. |
"" |
number, integer |
With decimal ".": commas are removed as thousands separators, so 1,250.5 is 1250.5. With ",": dots are removed and the comma becomes the decimal point, so 1.250,5 is 1250.5. Anything else: Year: "abc" is not a number. |
null |
boolean |
true if the cell, ignoring case, is one of yes; default list: true, yes, y, 1, x, ja, j. Every other non-empty text is false, including typing mistakes. |
null |
date |
Parsed by dateFormat: YYYY-MM-DD (default; also accepts a longer value that starts with it, such as a timestamp), DD.MM.YYYY, DD/MM/YYYY, MM/DD/YYYY, YYYYMMDD. Day and month may have one digit in the dotted and slashed forms. Otherwise: In service since: "x" is not a date in the form DD.MM.YYYY. |
"" |
ref |
The cell holds the old identifier of the referenced record. It is replaced by the new id of the record imported from that identifier, in this run or an earlier run from the same system. If there is none: Depot: depot "D-03" was not imported. |
"" |
After conversion the row goes through the engine like any other record, so every rule of the configuration applies:
required fields, ranges, patterns, choices, uniqueness. A value that converts but breaks a rule is refused with the
engine's message (Year: the smallest allowed value is 1990.).
definitions
| Property | Meaning |
|---|---|
file |
The source file. |
id |
The column with the option's identifier in the old system. |
fields |
Which column holds which property: name, group, kind, default, choices, unit, min, max, description. Properties you leave out are empty. |
Rules: kind must be boolean, number, text or selection in the source (an empty cell means text); an empty
group becomes General; choices are separated by |; default, min and max are converted by kind (numbers
with . as the decimal point, booleans with the default yes-list).
values
| Property | Meaning |
|---|---|
file |
The source file. |
definition |
Column with the option's old identifier. |
level |
Column with the record type id the value is set at (region, depot, …), as the configuration spells it. |
target |
Column with the old identifier of the record it is set for. |
value |
Column with the value, converted by the option's kind. |
id |
Optional column with the value's own identifier. Without it the identifier is built as <option>@<level>:<target>. |
Definitions are imported after all record types, values last. A value can refer to options and records imported in earlier runs from the same system.
Checks before anything runs
The mapping is checked against the configuration first; any problem stops the command:
countersign: The mapping is not usable:
- "system" (the name of the old system) is missing.
- Mapping for unknown record type "truck".
- vehicle: the configuration has no field "colour".
- vehicle: required field "plate" is not mapped.
The import command
npm run cli -- import --mapping <file> --dir <folder> \
--actor-id <id> --actor-name "<name>" --actor-roles <role,role> \
[--mode approved|review] [--dry-run] [--allow-partial] [--report <folder>]
| Option | Meaning |
|---|---|
--mapping <file> |
The mapping file. Required. |
--dir <folder> |
The folder with the source files. Required. |
--actor-id, --actor-name, --actor-roles |
The person running the migration and their role ids. Required. Recorded on every entry, together with the operating-system account ("auth": {"method": "cli", "issuer": "jdoe@ops-01"}). |
--mode |
approved (default) or review. |
--dry-run |
Rehearse only. Nothing is written to the store. |
--allow-partial |
Write the rows that pass even though others are refused. |
--report <folder> |
Where to write the report files. Default: the current folder. |
The store is the one named by COUNTERSIGN_STORAGE and friends, as for every CLI command
(Operations). The store must have a configuration (init) and no integrity
fault.
What happens, in order:
The mapping is checked against the configuration.
The source files are read and their SHA-256 checksums computed.
A rehearsal always runs first, on a copy of the state in memory, with the real engine. It produces the full list of what would be imported and what is refused.
With
--dry-runthe command stops here and writes the dry-run report.Without it: if the rehearsal refused any row and
--allow-partialis not given, nothing is written and the dry-run report is produced:The rehearsal refused 5 rows; nothing was written. Fix the source or the mapping, or pass --allow-partial.Otherwise every row is run against the store, one audit entry per record (two in review mode), and the final report is written.
| Exit code | Meaning |
|---|---|
| 0 | Rows were written (possibly with refusals, if --allow-partial), or a dry run without refusals. |
| 1 | The rehearsal refused rows and nothing was written; or the command could not start. |
The real run is not one transaction: each record is its own audit entry. If it is interrupted (the database goes away), the records imported so far stay, and running the same command again continues with the rest.
All entries carry the time of the import, not the times of the old system. Old approval dates travel as text in the origin.
The three report files
Named after the batch, in the report folder: migration-<batch>.md, .json and -cross-reference.csv; a rehearsal's
files have -dry-run after the batch.
| File | For | Content |
|---|---|---|
migration-<batch>.md |
The migration record, to print and sign | A header table (source system, batch, mode, who ran it and from which account, when, the configuration's fingerprint, the range of audit entries written or, in a dry run, the range a real run would write, and the head hash after the run); the source files with row counts and SHA-256 checksums; the reconciliation table (per record type: rows read, imported, already present, refused, and a line confirming that read = imported + already present + refused); every refused row with file, line, old id and reason; a sign-off table (performed by, reconciliation checked by, quality approval). |
migration-<batch>.json |
Tools | The same data in full: system, batch, mode, dryRun, configId, rows (one object per source row: file, line, type, legacyId, outcome, recordId, error), totals, firstSeq, lastSeq, headHash, sources, at. |
migration-<batch>-cross-reference.csv |
Looking records up later | One line per source row: type,legacy_id,record_id,outcome,file,line,error. The table that answers "which record is old number FB-1001 now?" |
outcome is imported, existing (already present from an earlier run) or failed (refused). line counts the
header as line 1, so the first data row is line 2, as a spreadsheet shows it.
A dry-run report says so in its title and in these two lines: "Audit entries a real run would write" gives the range the rehearsal produced in memory, and "Head hash" reads "unchanged: nothing was written". The record ids in a dry run are the ones the records would get if nothing else is created in between.
Keep the final report, the cross-reference, the mapping file and the source files together as the migration record. The checksums in the report tie it to exactly those source files.
Running it again
In approved mode the import is idempotent by source system and old id. Every imported record carries its origin,
and a row whose system and identifier (and record type) are already in the store is skipped and reported as
"already present". So you can:
- run the same command again after an interruption;
- fix refused rows in the source and run again: only the fixed rows are imported;
- import in waves (master data first, then the rest) with the same
systemname: references resolve against records from earlier waves.
A second run of the worked example below shows it:
class read 2, imported 0, already present 2, refused 0
region read 2, imported 0, already present 2, refused 0
depot read 3, imported 0, already present 2, refused 1
vehicle read 6, imported 0, already present 3, refused 3
definition read 3, imported 0, already present 3, refused 0
value read 4, imported 0, already present 3, refused 1
Its report says Audit entries written | none and Head hash after the run | unchanged.
The engine guards this too: an import command for an origin that exists is refused with
FleetBase 4 FB-1001 was already imported.
What idempotence does not do:
- It does not update. A row that was imported and has since changed in the source is skipped, not re-imported. After cut-over the old system must be frozen; later corrections are made in Countersign, through the workflow.
- It depends on the
systemname and the identifiers being the same in every run. - It does not apply to
reviewmode. Records created in review mode carry their origin only as a note, so a second run cannot recognise them. Rows whose record type has a unique field are then refused as duplicates (Name Van is already used by VC-0001.), and their dependants with… was not imported; rows of a type without a unique field would be created a second time. Run a review-mode import once per batch, make the dry run clean first, and do not combine--mode reviewwith repeated--allow-partialruns. If a review-mode run was interrupted, remove the already imported rows from the source files before continuing.
Handling refused rows
Every refused row is in the report with its file, line, old id and reason. Typical reasons and what to do:
| Reason in the report | Cause | Remedy |
|---|---|---|
Fuel: no mapping for "H2" |
A code the map does not know. |
Add it to the map, or correct the source. |
Year: the smallest allowed value is 1990. |
The data breaks a rule of the configuration. | Decide: is the data wrong (fix the source) or the rule too strict (change the configuration, under change control)? |
Region: region "XX" was not imported |
The referenced record is not in its file, or was itself refused. | Fix the reference or add the missing record. Rows that depend on a refused row are refused too: fix the first cause and the rest follows. |
Plate KI-AB 101 is already used by VH-0001. |
A duplicate in the source, or the record exists already. | De-duplicate. |
In service since: "31/12/21" is not a date in the form DD.MM.YYYY |
Mixed formats in one column. | Clean the column, or split the file. |
the row has no identifier |
The id column is empty. | Give the row an identifier. |
column "lease" is not in the file |
The mapping names a column the file does not have (a typing mistake, or a header changed). | Fix the mapping. |
As Quality Assurance you can't import records. A role needs the "import" permission. (every row) |
The person running it lacks the permission for the mode. | Use the right --actor-roles; in review mode the roles need the author permissions. |
The largest allowed value is 60000. |
An option value outside its definition's range. | Fix the value or the definition. |
Work in this order:
- Make the dry run clean, or reduce the refusals to rows you decide not to migrate.
- Record each such decision (the report lists the rows; add the justification in your migration record).
- Only then run for real, with
--allow-partialif refusals remain on purpose.
Prefer fixing the source and re-exporting over editing the export by hand, so that the checksummed files are what the old system produced. Where hand corrections are unavoidable, document them.
What is not migrated
| Not carried over | Why | What to do |
|---|---|---|
| The old system's audit trail | Countersign's audit trail records what happened in Countersign. Entries cannot be back-dated or attributed to actions outside it; a fabricated history would not be an audit trail. | Archive the old trail (below) and reference it. |
| The old version history of each record | A record arrives as version 1 with its current approved data. | The same archive. If earlier versions matter for daily work, keep the old system readable for a time. |
| The old approvals as signatures | The old approver and date are carried as recorded text in the origin (approvedBy, approvedAt); they are information, not a Countersign signature. |
Nothing; the report and the audit entry say so explicitly. |
| Original creation and change times | Entries carry the time of the import. | In the archive. A date that matters to the business belongs in a date field of the record. |
| Records in progress (drafts, pending approvals) | Approved mode brings approved data only. | Finish or cancel them in the old system before cut-over, or bring them in a separate review run. |
| Retired or deleted records | Everything imported is active. | Leave them in the archive, or import and then retire them through the workflow. |
| Users and their roles | People come from the identity provider. | Assign roles there. |
| Attachments and files | Countersign has no attachments. | Keep them in a document system and put its reference in a text field. |
Archiving the old audit trail and referencing it
- Export the old system's complete audit trail and version history in a durable, readable format (PDF/A, CSV with a description of its columns), together with a description of the old system and its user list.
- Compute checksums, store the archive read-only in your records archive for the retention period, and test that it can be read back.
- Record its location and checksums in the migration record.
- The link from a Countersign record to its history in the archive is the origin: every imported record shows
the system it came from, its identifier there and the batch, and the cross-reference CSV maps both ways. A
reviewer who needs the history of
VH-0001before the migration looks upFleetBase 4,FB-1001in the archive.
What an imported record carries, as its import audit entry shows it:
"after": {
"data": { "name": "West", "code": "WE" },
"origin": { "system": "FleetBase 4", "id": "WE", "batch": "MIG-2026-11" }
},
"reason": "Imported from FleetBase 4, id WE.",
"auth": { "method": "cli", "issuer": "root@vm", "authTime": "2026-11-11T11:19:04.607Z" }
and the version in the record export:
{
"v": 1, "state": "approved", "lifecycle": "active",
"author": "jdoe",
"approvedAt": "2026-11-11T11:19:04.622Z",
"meaning": "Migrated from FleetBase 4 as approved there (batch MIG-2026-11).",
"note": "Imported from FleetBase 4, id FB-1001.",
"origin": { "system": "FleetBase 4", "id": "FB-1001", "batch": "MIG-2026-11", "approvedBy": "R. Albers", "approvedAt": "2021-03-20" }
}
There is deliberately no approvedBy on the version itself: nobody approved it here. From the first change onwards,
an imported record is under the normal workflow: an edit opens version 2, which a second person approves.
Validation considerations
A migration into a validated system is itself subject to validation. Points to cover; the validation pack has the surrounding documents.
- Plan it. A migration plan with scope, mapping specification, acceptance criteria (for example: 100 % of rows accounted for, zero unexplained refusals, a defined sample compared field by field), roles and the fallback.
- Rehearse in a qualified test environment first, with the same software version, the same configuration (compare the fingerprints: the report prints the configuration's SHA-256) and a full copy of the source data. The production run should be a repetition of something that has already worked.
- Source file checksums. The report lists the SHA-256 of every source file. Compare them with the checksums taken at export, so that what was imported is provably what was exported, and the same files as in the last clean dry run.
- Reconciliation. The report's table proves that every row read is accounted for. Add the comparison with the old system's own counts, and a field-by-field check of a sample by the data owners using the cross-reference.
- The import tool is tested software: its behaviour is covered by requirements FR-MIG-04 to FR-MIG-08 and the
tests in
lib/migrate.test.ts. Your mapping file is yours to verify: the dry run and the sample check are that verification. - Integrity after the run.
npm run cli -- verify, then anchor the new head hash (Operations). The report records the range of entries written and the head hash after the run. - Attribution. Run the import as a named person with the
importrole, from a controlled account; both are in every entry. - Sign the report. Performed by, checked by, quality approval.
- Keep the plan, mapping specification, mapping file, source files, dry-run and final reports, cross-reference,
verifyoutput and the archive reference together.
Worked example
examples/migration/ holds a small export from an imaginary fleet system, "FleetBase 4", and the mapping that brings
it into the fleet preset: regions.csv, depots.csv (semicolon-separated), classes.csv, vehicles.csv,
options.csv, option-values.csv and mapping.json. Five rows are wrong on purpose (see the folder's
README).
Prepare a scratch store
$ export COUNTERSIGN_STORAGE=sqlite COUNTERSIGN_DATA_FILE=./scratch/migration.sqlite
$ npm run cli -- init --config preset:fleet
Initialised the sqlite store with 1 entry from preset:fleet. Head #1 2aa83680a5e956bf9326dc34b985cdd17f22ad04d746c133e87bafec90cda803
Dry run
$ npm run cli -- import --mapping examples/migration/mapping.json --dir examples/migration \
--actor-id jdoe --actor-name "Jane Doe" --actor-roles system-owner --dry-run --report ./scratch
wrote scratch/migration-MIG-2026-11-dry-run.md
wrote scratch/migration-MIG-2026-11-dry-run.json
wrote scratch/migration-MIG-2026-11-dry-run-cross-reference.csv
class read 2, would import 2, already present 0, refused 0
region read 2, would import 2, already present 0, refused 0
depot read 3, would import 2, already present 0, refused 1
vehicle read 6, would import 3, already present 0, refused 3
definition read 3, would import 3, already present 0, refused 0
value read 4, would import 3, already present 0, refused 1
$ echo $?
1
The report, scratch/migration-MIG-2026-11-dry-run.md:
# Migration report: MIG-2026-11 (dry run: nothing was written)
| | |
| --- | --- |
| Source system | FleetBase 4 |
| Batch | MIG-2026-11 |
| Mode | imported as approved in the source system |
| Run by | Jane Doe (jdoe) via root@vm |
| Run at (UTC) | 2026-11-11T12:03:31.242Z |
| Configuration | fleet, SHA-256 547e2d8aea0405603c034b4fc1637fcc516e7ebbb59e5ad5509236947104cd8d |
| Audit entries a real run would write | #2 to #16 |
| Head hash | unchanged: nothing was written |
## Source files
| File | Rows | SHA-256 |
| --- | ---: | --- |
| vehicles.csv | 6 | `a8b853c83ab411b86747feeb446ec34c5713e65e01510b28a261e977e785a19a` |
| regions.csv | 2 | `52d800f7475b3857405e23bb15dd440cf05d09c286079762f7c1154b67ca8068` |
| depots.csv | 3 | `289004dba3dc2da81a5198d2b7ea191cb6e06958f67241815504d363e4ce748a` |
| classes.csv | 2 | `f34995556b4bcc7b0536ca647de95d9a68463eb22ca86cb22a5e00ebb86f06a7` |
| options.csv | 3 | `39bd35fce7b20e4027994eb338a9512d90535a0165a4aa2d19df0008ed48d8c4` |
| option-values.csv | 4 | `1eb0b754b365389a65f854295ca31db0a3a3e118ff0d02bfc54b262779daddf3` |
## Reconciliation
| Record type | Rows read | Imported | Already present | Refused |
| --- | ---: | ---: | ---: | ---: |
| class | 2 | 2 | 0 | 0 |
| region | 2 | 2 | 0 | 0 |
| depot | 3 | 2 | 0 | 1 |
| vehicle | 6 | 3 | 0 | 3 |
| definition | 3 | 3 | 0 | 0 |
| value | 4 | 3 | 0 | 1 |
Rows read = imported + already present + refused for every record type: yes.
## Refused rows
| File | Line | Type | Old id | Reason |
| --- | ---: | --- | --- | --- |
| depots.csv | 4 | depot | D-03 | Region: region "XX" was not imported |
| vehicles.csv | 5 | vehicle | FB-1004 | Year: the smallest allowed value is 1990. |
| vehicles.csv | 6 | vehicle | FB-1005 | Depot: depot "D-03" was not imported |
| vehicles.csv | 7 | vehicle | FB-1006 | Fuel: no mapping for "H2" |
| option-values.csv | 5 | value | SERVICE_KM@vehicle:FB-1001 | The largest allowed value is 60000. |
## Sign-off
| Role | Name | Signature | Date |
| --- | --- | --- | --- |
| Migration performed by | | | |
| Reconciliation checked by | | | |
| Quality approval | | | |
The cross-reference, scratch/migration-MIG-2026-11-dry-run-cross-reference.csv:
type,legacy_id,record_id,outcome,file,line,error
class,VAN,VC-0001,imported,classes.csv,2,
class,CAR,VC-0002,imported,classes.csv,3,
region,NO,RG-0001,imported,regions.csv,2,
region,WE,RG-0002,imported,regions.csv,3,
depot,D-01,DP-0001,imported,depots.csv,2,
depot,D-02,DP-0002,imported,depots.csv,3,
depot,D-03,,failed,depots.csv,4,"Region: region ""XX"" was not imported"
vehicle,FB-1001,VH-0001,imported,vehicles.csv,2,
vehicle,FB-1002,VH-0002,imported,vehicles.csv,3,
vehicle,FB-1003,VH-0003,imported,vehicles.csv,4,
vehicle,FB-1004,,failed,vehicles.csv,5,Year: the smallest allowed value is 1990.
vehicle,FB-1005,,failed,vehicles.csv,6,"Depot: depot ""D-03"" was not imported"
vehicle,FB-1006,,failed,vehicles.csv,7,"Fuel: no mapping for ""H2"""
definition,SERVICE_KM,OD-0001,imported,options.csv,2,
definition,WINTER,OD-0002,imported,options.csv,3,
definition,TRACK,OD-0003,imported,options.csv,4,
value,WINTER@region:NO,OV-0001,imported,option-values.csv,2,
value,SERVICE_KM@class:VAN,OV-0002,imported,option-values.csv,3,
value,TRACK@vehicle:FB-1002,OV-0003,imported,option-values.csv,4,
value,SERVICE_KM@vehicle:FB-1001,,failed,option-values.csv,5,The largest allowed value is 60000.
Things to notice: the mapping lists vehicles first, yet classes, regions and depots were imported before them;
depots.csv is semicolon-separated and was read without being told; the vehicle FB-1005 fails only because its
depot failed.
A real run stops while rows are refused
$ npm run cli -- import --mapping examples/migration/mapping.json --dir examples/migration \
--actor-id jdoe --actor-name "Jane Doe" --actor-roles system-owner --report ./scratch
The rehearsal refused 5 rows; nothing was written. Fix the source or the mapping, or pass --allow-partial.
…
$ npm run cli -- status | head -2
store sqlite
entries 1
In a real migration you would now fix the five rows. To see a run complete, accept them:
A real run
$ npm run cli -- import --mapping examples/migration/mapping.json --dir examples/migration \
--actor-id jdoe --actor-name "Jane Doe" --actor-roles system-owner --allow-partial --report ./scratch
wrote scratch/migration-MIG-2026-11.md
wrote scratch/migration-MIG-2026-11.json
wrote scratch/migration-MIG-2026-11-cross-reference.csv
class read 2, imported 2, already present 0, refused 0
region read 2, imported 2, already present 0, refused 0
depot read 3, imported 2, already present 0, refused 1
vehicle read 6, imported 3, already present 0, refused 3
definition read 3, imported 3, already present 0, refused 0
value read 4, imported 3, already present 0, refused 1
The header of the final report:
# Migration report: MIG-2026-11
| | |
| --- | --- |
| Source system | FleetBase 4 |
| Batch | MIG-2026-11 |
| Mode | imported as approved in the source system |
| Run by | Jane Doe (jdoe) via root@vm |
| Run at (UTC) | 2026-11-11T11:19:04.631Z |
| Configuration | fleet, SHA-256 547e2d8aea0405603c034b4fc1637fcc516e7ebbb59e5ad5509236947104cd8d |
| Audit entries written | #2 to #16 |
| Head hash after the run | 7cdb4906fe01bb67f5e417696f80a7da76d3738b1445d2643fd21bc67741d708 |
and the store afterwards:
$ npm run cli -- status
store sqlite
entries 16
head hash 7cdb4906fe01bb67f5e417696f80a7da76d3738b1445d2643fd21bc67741d708
configuration fleet "Fleet configuration", revision 1, SHA-256 547e2d8aea0405603c034b4fc1637fcc516e7ebbb59e5ad5509236947104cd8d
records 15 (15 including removed)
integrity ok
$ npm run cli -- verify
entries read 16
hash chain intact
replay all 16 entries reproduce
head hash 7cdb4906fe01bb67f5e417696f80a7da76d3738b1445d2643fd21bc67741d708
Fifteen records, fifteen import entries after the configuration entry, and the head hash in the report equals the
store's. Running the command a third time imports nothing (Running it again).
The same files in review mode
On a fresh store, as a person who holds the authoring roles:
$ npm run cli -- import --mapping examples/migration/mapping.json --dir examples/migration \
--actor-id mig --actor-name Migrator \
--actor-roles coordinator,class-owner,depot-manager,regional-lead,steward \
--mode review --allow-partial --report ./scratch
class read 2, imported 2, already present 0, refused 0
region read 2, imported 2, already present 0, refused 0
depot read 3, imported 2, already present 0, refused 1
vehicle read 6, imported 3, already present 0, refused 3
definition read 3, imported 3, already present 0, refused 0
value read 4, imported 3, already present 0, refused 1
The report says Mode | created and confirmed, awaiting approval here and Audit entries written | #2 to #31: two
entries per record. The fifteen records now sit in the approvers' inboxes as Confirmed, each with the note
Migrated from FleetBase 4, id VAN (batch MIG-2026-11). The person who ran the import is their author and therefore
cannot approve them. With the system-owner role alone, review mode refuses every row
(As System Owner you can't create a vehicle class.).
Coming from spreadsheets
Many registers start as spreadsheets. The steps are the same; a few things are particular.
- One sheet per record type, one row per record, one header row, no merged cells, no totals or notes below the data. Split a sheet that mixes levels (a site column repeated on every instrument row) into one sheet per level, with the parent's identifier as a column on the child.
- Give every row an identifier that will not change. If there is none, add a column: an existing inventory
number, or a simple running number per sheet (
S-001, …). Do not use the row number. - Save each sheet as CSV in UTF-8 ("CSV UTF-8" in Excel). A semicolon-separated file from a German Excel is recognised.
- Clean before you map. The usual finds: the same thing spelled several ways (
Diesel,diesel,D) → onemap; dates as text in mixed formats → one format per column; numbers with units in the cell (220 g) → split;xor empty for yes/no → the default yes-list coversx,ja,yes,1; leading and trailing spaces are trimmed for you. - Decimal commas:
{"column": "max", "decimal": ","}. - Decide the mode honestly. A spreadsheet rarely has evidence of who approved what.
reviewputs every record in front of an approver. If you chooseapproved, document why the spreadsheet's content may be taken as approved (for example a signed printout). - There is no
approvedBycolumn to map unless the sheet has one.
A mapping for a German spreadsheet of sites, as tried against the laboratory example configuration
(examples/config/lab-equipment.json):
Standort;Kuerzel;Land;GMP
Basel;BSL;Switzerland;ja
Cork;CRK;Ireland;nein
{
"system": "Equipment spreadsheet",
"batch": "LAB-MIG-01",
"entities": [
{ "type": "site", "file": "sites.csv", "id": "Kuerzel",
"fields": { "name": "Standort", "code": "Kuerzel", "country": "Land", "gmp": "GMP" } }
]
}
With laboratories, classes and instruments mapped the same way (instrument dates as
{"column": "last", "dateFormat": "DD.MM.YYYY"}, the upper range as {"column": "hi", "decimal": ","}), the run
reported:
site read 2, imported 2, already present 0, refused 0
laboratory read 1, imported 1, already present 0, refused 0
instrument-class read 1, imported 1, already present 0, refused 0
instrument read 1, imported 1, already present 0, refused 0
After the migration, lock the spreadsheet (read-only, archived with its checksum): from now on the register is in Countersign.
Moving between Countersign stores
Moving a Countersign installation from one store to another (SQLite to PostgreSQL, one database server to another)
is not a migration in the sense of this page, and import is the wrong tool for it: an import would create new
records with new audit entries and lose the history.
Move the audit trail itself instead. The trail is the whole system; copied entry by entry it gives the same records,
the same versions, the same signatures and the same head hash in the new store. The supported routes (database
backup and restore between stores of the same kind, the JSONL export for the rest) and the procedure with
verify --expect-head are in Storage.
To combine two Countersign installations into one there is no tool: their trails are separate chains. Export the
records of one (export --what records), convert them to source files, and import them into the other as a
migration from a system named after the first installation, archiving its trail as described above.