Migration assistant
When Countersign replaces an existing application, most of the migration project can be prepared by a tool: reading
the old system's data model, drafting the configuration and the mapping, listing what the old system does that
Countersign does differently or not at all, exporting the data, rehearsing the import and checking the result field
by field. scripts/migrate.ts does that, and an agent skill drives it. This page describes both. The import itself,
its two modes and its reports are described in Migrating from another system; read that first if you
only have a few spreadsheets to bring over.
Contents
- What it does and what it leaves to you
- With a coding agent
- By hand
- The migration folder
- plan: survey, drafts and the guide
- extract: a read-only export
- rehearse: import and verify in a scratch store
- The cut-over and verify
- journal: what was done, from what
- How the draft decides
- The source model format
- Limits
What it does and what it leaves to you
flowchart LR
subgraph old["The system you replace"]
repo["Repository<br/>schema, code, settings"]
db[("Database")]
end
subgraph folder["Migration folder"]
guide["MIGRATION-GUIDE.md<br/>parity, gaps, decisions"]
drafts["config.json<br/>mapping.json<br/>extract-plan.json<br/>countersign.env"]
source["source/*.csv<br/>extract-manifest.json"]
reh["rehearsal/<br/>import report<br/>verification"]
journal["journal.jsonl"]
end
repo -- plan --> guide
repo -- plan --> drafts
db -- "plan --db" --> drafts
db -- "extract (read-only)" --> source
drafts -- rehearse --> reh
source -- rehearse --> reh
reh -. "fix and repeat" .-> drafts
drafts == "cut-over: cli import, by a named person" ==> prod[("Countersign<br/>production store")]
source == "cut-over" ==> prod
prod -- verify --> ver["verification report"]
| Step | Automated | Yours |
|---|---|---|
| Inventory | Every table of the old system with its fate (record type, archive, identity provider, gap) and row count; the capabilities seen in its code, with places | Confirming each line: the scan finds words, not meaning |
| Parity and gap analysis | A table of 18 capabilities: seen or not, and whether Countersign covers it, covers it differently, partly, or not | Deciding what to do about each gap |
| Configuration | A valid first configuration: record types, fields, types, choices, required and unique, references, hierarchy | Reviewing it; the roles; labels in your language |
| Mapping | A mapping that fits the configuration, and the export queries | Conditions that leave rows out; conversions the draft could not know |
| Settings | An environment file for Countersign with the old variable names as pointers, no values | The values, the app registration at the identity provider, the database |
| Export | Read-only queries, CSV files, a manifest with each query, row count and checksum | Access to the old database; freezing it at cut-over |
| Rehearsal | A scratch store, the real import, the import report, a comparison of every imported field with its source | Reading the refusals and fixing source, mapping or configuration |
| Cut-over | The exact commands | Running them as a named person; the decision to go |
| Verification | The same field-by-field comparison against production, the chain check | A sample compared by hand; signatures |
| Evidence | A hash-chained journal of every step with the checksums of its inputs and outputs | Filing it with the migration record |
Nothing the assistant does writes to the old system, and nothing writes to a Countersign store except the rehearsal
(its own scratch store, recreated each time). The production import stays a deliberate act with
npm run cli -- import, as described in Migration.
With a coding agent
The repository carries a skill, .claude/skills/migrate-to-countersign/SKILL.md. Clone Countersign next to the
repository you want to migrate, open a coding agent in the Countersign folder and ask:
your-projects/
countersign/ ← this repository; start the agent here
supplierbook/ ← the system to migrate
Migrate ../supplierbook to Countersign.
Claude Code picks the skill up by itself (or call it with /migrate-to-countersign ../supplierbook). Other agents
read AGENTS.md, which points at the same file; it is plain Markdown. The agent runs the tool described below,
reads the old system's code at the places the survey names, answers the decisions it can answer from the code, asks
you the ones it cannot (the import mode, the roles, what to do about each gap), repeats the rehearsal until it is
clean, and hands you MIGRATION-GUIDE.md with its assessment and the commands for the cut-over. It does not run
the production import.
By hand
The same steps without an agent, on the example system in
examples/legacy-app ("SupplierBook 2", an invented supplier register with twelve
tables, its own passwords, e-mail, uploads and an Excel report). First a scratch copy of its database:
$ node -e 'const {DatabaseSync}=require("node:sqlite");const fs=require("fs");fs.mkdirSync("scratch",{recursive:true});fs.rmSync("scratch/supplierbook.sqlite",{force:true});const d=new DatabaseSync("scratch/supplierbook.sqlite");for(const f of ["schema","seed"])d.exec(fs.readFileSync(`examples/legacy-app/db/${f}.sql`,"utf8"))'
1. Plan
$ npm run migrate -- plan examples/legacy-app --system "SupplierBook 2" \
--db sqlite:scratch/supplierbook.sqlite --dir scratch/supplierbook
wrote scratch/supplierbook/survey.json
wrote scratch/supplierbook/source-model.json
wrote scratch/supplierbook/draft.json
wrote scratch/supplierbook/config.json
wrote scratch/supplierbook/mapping.json
wrote scratch/supplierbook/extract-plan.json
wrote scratch/supplierbook/countersign.env
wrote scratch/supplierbook/notes.md
wrote scratch/supplierbook/MIGRATION-GUIDE.md
journal #1 plan: read the SQLite database …/scratch/supplierbook.sqlite; 12 tables, 5 record types, 9 capabilities seen, 9 open decisions
Next: read scratch/supplierbook/MIGRATION-GUIDE.md, answer its decisions, then extract and rehearse.
The guide's parity table, shortened:
| Capability | Seen in the old system | Countersign | What to do |
|---|---|---|---|
| Approval by a second person | 21 lines in 4 files: app/main.py:31, … |
Covered | Compare the old approval steps with this one workflow. |
| Audit trail | 4 lines in 3 files: app/main.py:24, … |
Covered | The old trail cannot be imported. Archive it read-only. |
| Accounts and passwords kept by the application | 3 lines in 2 files: app/main.py:9, … |
Gap | Every user needs an account at an identity provider before cut-over. |
| Attachments and uploaded files | 3 lines in 3 files: app/main.py:15, … |
Gap | Keep the files in a document system and carry its reference in a text field. |
| Notifications (e-mail, chat, webhooks) | 2 lines in 1 file: app/main.py:46, … |
Gap | Decide whether people check the inbox by routine, or build a notifier. |
| Reports and exports (PDF, Excel) | 7 lines in 4 files: app/main.py:59, … |
Partly covered | Rebuild the reports people need on the exports or the view. |
| Scheduled or background jobs | not seen | – | – |
its table of tables:
| Table | Rows | Fate |
|---|---|---|
| approvals | 1 | Not migrated: archive |
| audit_log | 1 | Not migrated: archive |
| certificate_types | 2 | Becomes a record type: certificate-type |
| material_categories | 3 | Becomes a record type: material-category |
| materials | 7 | Becomes a record type: material |
| schema_migrations | 1 | Not migrated: technical |
| settings | 1 | Decision: options |
| sites | 5 | Becomes a record type: site |
| supplier_certificates | 3 | Decision: many-to-many |
| supplier_documents | 1 | Gap: attachments |
| suppliers | 4 | Becomes a record type: supplier |
| users | 2 | Not migrated: identity provider |
and some of its twelve decisions:
| # | About | Decision | |
|---|---|---|---|
| D-01 | Open | materials.is_deleted | Rows the old system marks as deleted or inactive through "is_deleted": everything imported is active. Exclude them with a "where" in the extract plan, or import them and retire them through the workflow. |
| D-02 | Review | materials | "Site" (site) was taken as the parent: records sit under it in the tree and inherit its options. "Category" (material-category) was taken as a grouping across parents that also passes options on. … |
| D-06 | Open | supplier_certificates | A many-to-many link between suppliers and certificate_types: a record refers to one other record per field, so this needs a decision. … |
| D-08 | Open | suppliers.status | The old workflow state (draft, pending, approved, blocked). Approved mode must bring approved rows only: add a condition such as "status = 'approved'" as "where" to this table in the extract plan … |
The drafted hierarchy, read off the foreign keys:
flowchart TD
supplier["Supplier (SP)"] --> site["Site (ST)"]
site --> material["Material (MT)"]
category["Material category (MC)"] -. "groups" .-> material
ct["Certificate type (CT)"]
2. Decide
Answer the decisions: edit the drafts and write the reasons into notes.md. Here, D-08 and D-01 are answered by two
conditions in extract-plan.json:
{ "table": "suppliers", "file": "suppliers.csv", "columns": ["id", "name", "…"], "orderBy": ["id"], "where": "status = 'approved'" },
{ "table": "materials", "file": "materials.csv", "columns": ["id", "item_number", "…"], "orderBy": ["id"], "where": "is_deleted = 0" }
npm run cli -- config validate scratch/supplierbook/config.json checks the configuration after editing.
3. Extract
$ npm run migrate -- extract --dir scratch/supplierbook --from sqlite:scratch/supplierbook.sqlite
certificate_types 2 rows → source/certificate_types.csv
material_categories 3 rows → source/material_categories.csv
materials 6 rows of 7 → source/materials.csv
sites 5 rows → source/sites.csv
suppliers 3 rows of 4 → source/suppliers.csv
wrote scratch/supplierbook/extract-manifest.json
journal #2 extract: 19 rows in 5 files from sqlite …/scratch/supplierbook.sqlite
4. Rehearse
$ npm run migrate -- rehearse --dir scratch/supplierbook
Initialised the sqlite store with 1 entry from …/scratch/supplierbook/config.json. Head #1 40315d41…
certificate-type read 2, imported 2, already present 0, refused 0
material-category read 3, imported 3, already present 0, refused 0
supplier read 3, imported 3, already present 0, refused 0
site read 5, imported 4, already present 0, refused 1
material read 6, imported 4, already present 0, refused 2
…
certificate-type 2 rows, 2 records compared, 2 fields, 0 differ, 0 without a record
material-category 3 rows, 3 records compared, 9 fields, 0 differ, 0 without a record
material 6 rows, 4 records compared, 28 fields, 0 differ, 0 without a record
site 5 rows, 4 records compared, 20 fields, 0 differ, 0 without a record
supplier 3 rows, 3 records compared, 15 fields, 0 differ, 0 without a record
journal #3 rehearse: 19 rows: 16 imported, 3 refused; 0 fields differ, 0 rows without a record
Not clean yet: read rehearsal/migration-MIG-01.md (refused rows) and the verification next to it.
$ echo $?
1
The refused rows, from rehearsal/migration-MIG-01.md:
| File | Line | Type | Old id | Reason |
|---|---|---|---|---|
| sites.csv | 6 | site | 5 | Supplier: supplier "4" was not imported |
| materials.csv | 6 | material | 5 | Site: site "5" was not imported |
| materials.csv | 7 | material | 6 | Enter name. |
The first two follow from leaving the pending supplier out, which is what was decided; the third is a material without a name in the old data, to be corrected there. Fix, extract and rehearse again until the rehearsal is clean or every refusal is one you accept.
5. Cut over and verify
On the production store (set up as in Deployment, initialised with
npm run cli -- init --config scratch/supplierbook/config.json), with the old system frozen and a fresh extract:
$ npm run cli -- import --mapping scratch/supplierbook/mapping.json --dir scratch/supplierbook/source \
--actor-id jdoe --actor-name "Jane Doe" --actor-roles administrator --report scratch/supplierbook/production
$ npm run migrate -- verify --dir scratch/supplierbook --report scratch/supplierbook/production/migration-MIG-01.json
$ npm run cli -- verify
$ npm run migrate -- journal --dir scratch/supplierbook
The migration folder
Everything about one migration lives in one folder (migration/<system>/ by default, which .gitignore leaves out:
it holds data of the old system). File it with the migration record when you are done.
| File | Written by | What it is |
|---|---|---|
MIGRATION-GUIDE.md |
plan, guide |
The guide: parity and gaps, every table and column, decisions, settings, the steps with their commands, the last rehearsal. Generated; do not edit. |
notes.md |
you | Your assessment and the answers to the decisions. Never overwritten; shown in the guide as "Assessment". |
config.json |
plan, then you |
The configuration. |
mapping.json |
plan, then you |
The mapping, in the format of Migration. |
extract-plan.json |
plan, then you |
One entry per table to export: table, file, columns, orderBy, optional where. |
countersign.env |
plan, then you |
A draft of Countersign's environment. No secrets, no values from the old system. |
survey.json, source-model.json, draft.json |
plan |
The raw findings, the data model as read, and the draft's table fates, column fates and decisions. |
source/*.csv, extract-manifest.json |
extract |
The exported data, and for each file the query, the rows exported, the rows in the table and the SHA-256. |
rehearsal/ |
rehearse |
The scratch store, the import's three report files, verification-<batch>.md and .json, summary.json. |
journal.jsonl |
every step | One line per step, chained. |
Once you have edited a draft, plan no longer overwrites it: a newer draft is written next to it as
config.draft.json (and so on) for you to compare.
plan: survey, drafts and the guide
npm run migrate -- plan <folder of the old system> [--system "<name>"] [--batch <id>] [--dir <folder>]
[--db sqlite:<file> | --db postgres] [--model <file>]
| Option | Meaning |
|---|---|
--system |
The old system's name as it will stand on every imported record. Default: the folder's name. Keep it the same in every run. |
--batch |
The batch identifier of the mapping. Default MIG-01. |
--dir |
The migration folder. Default migration/<system>. |
--db sqlite:<file> |
Read the data model and the row counts from a SQLite database (opened read-only). |
--db postgres |
Read them from the PostgreSQL database in the environment variable SOURCE_DATABASE_URL, in one read-only transaction. The address is never written anywhere. |
--model <file> |
Use a data model written by hand (format). |
Without --db or --model, the data model is read from the repository: every .sql file with CREATE TABLE
statements (PostgreSQL, MySQL, SQLite and SQL Server dialects; pg_dump --schema-only output works) and every Prisma
schema. A database is the better source: it is what really exists, it gives row counts, and it works whatever the
application is written in. For an application whose model exists only as code (Django models, Rails migrations, JPA
entities) and whose database you cannot reach, the guide lists those files, and the model is written by hand or by
the agent.
The survey reads text files up to 1 MB outside dependency and build folders. It looks for 18 capabilities by
telltale words (the list is CAPABILITIES in lib/adopt/survey.ts, with the words, Countersign's standing and the
advice) and reports matching lines and example places. It reads the names of environment variables from
.env* and env.example files and from the code, never their values.
extract: a read-only export
npm run migrate -- extract --dir <folder> --from sqlite:<file> | postgres | csv:<folder>
For each entry of extract-plan.json it runs
SELECT <columns> FROM <table> [WHERE <where>] ORDER BY <orderBy> and writes source/<file> as UTF-8 CSV.
| Source | How it is read |
|---|---|
sqlite:<file> |
The file is opened read-only. Its checksum goes into the journal. |
postgres |
SOURCE_DATABASE_URL; everything in one REPEATABLE READ READ ONLY transaction, so all tables are exported from the same moment and nothing can be written. Values arrive as PostgreSQL prints them (t/f for booleans, which the drafted mapping expects). Use an account that has only SELECT. |
csv:<folder> |
Exports you made by other means (another database, a vendor tool): copied byte for byte after checking that the planned columns are there. |
extract-manifest.json records who ran it, when, against which source (host, database, schema and account for
PostgreSQL; never the password), and per file the exact query, the rows exported, the rows in the table and the
SHA-256. The same checksums appear in the import's report and in the verification, which ties the three together.
A where condition is SQL that you write and that is run as you wrote it, against a read-only connection. It is
recorded in the manifest, so what was left out is on file.
rehearse: import and verify in a scratch store
npm run migrate -- rehearse --dir <folder> [--mode approved|review]
- Checks
config.jsonandmapping.jsonagainst each other. - Creates a fresh SQLite store in
rehearsal/and initialises it with the configuration. - Runs the real import (
scripts/countersign.ts import, the command the cut-over uses) with--allow-partial, as the person "Migration rehearsal" holding the roles that may import (in review mode: all roles). - Reads the store back, checks the hash chain, replays every entry, and compares every imported record with its source row (how).
- Writes the reports and
summary.json; the exit code is 1 while rows are refused or anything differs.
The cut-over and verify
The production import is npm run cli -- import, run by a named person with the import permission
(Migration). Afterwards:
npm run migrate -- verify --dir <folder> --report <path to the import's migration-<batch>.json>
with the same COUNTERSIGN_* environment as the import. It reads the whole audit trail from the store, checks the
chain and replays it (independently of any running server), then for every row of every source file:
| Check | A difference is reported as |
|---|---|
| The row is in the import's report and its record exists | "Rows without a record" |
Text and selections: the stored text equals the cell (after the mapping's map) |
not the same text |
| Numbers: the same value, read with the mapping's decimal sign | not the same number |
Dates: the same year, month and day, read in the mapping's dateFormat |
not the same day |
Yes/no: the same meaning by the mapping's yes list |
not the same yes/no value |
| References: the referenced record is the one that came from the referenced source row | the record points at … |
| Approved mode: the record carries this row as its origin, the old approver as given, and version 1 is Approved | origin, state |
| The source files have the checksums the import's report recorded | "Same as imported: NO" |
The comparison is written separately from the import's conversion on purpose: the import turns cells into values,
the verification asks whether a stored value means what the cell says. Only the version the import wrote is
compared; changes made in Countersign afterwards are not differences. Option definitions and values imported
through the mapping's definitions and values are reconciled by the import's report but not compared field by
field.
verification-<batch>.md has the result per record type, every difference and a sign-off table; the exit code is 1
if anything differs.
journal: what was done, from what
Every command appends one line to journal.jsonl: step, time, operating-system account and machine, the tool's
version and commit, its options, the checksums of what it read and wrote, and a one-line result. Each line carries
the hash of the line before it.
$ npm run migrate -- journal --dir scratch/supplierbook
#1 2026-11-11T14:39:45.332Z plan jdoe@ops-01 read the SQLite database …; 12 tables, 5 record types, 9 capabilities seen, 9 open decisions
#2 2026-11-11T14:39:45.686Z extract jdoe@ops-01 19 rows in 5 files from sqlite …
#3 2026-11-11T14:39:46.607Z rehearse jdoe@ops-01 19 rows: 16 imported, 3 refused; 0 fields differ, 0 rows without a record
chain intact, 3 steps, head 4e1f5edcc0e8957dd69db6f2b4afc45dbb0c691759f07085436400a3f33bfd22
edited extract-plan.json (written by #1 plan)
results all as written
"edited" names drafts you changed since the assistant wrote them (expected). A result file (an export, a report)
that differs from what its step wrote is CHANGED, a removed one MISSING, and the exit code is 1. The journal
answers, for an auditor: which export was this report made from, with which mapping and configuration, by whom, with
which version of the tool, and has anything been touched since.
The journal is evidence about the preparation. The record of the migration itself is in Countersign's audit trail:
one import entry per record, with the person, the time and the origin.
How the draft decides
Every rule below produces either a table fate, a column fate or a decision in the guide; nothing is decided silently.
Tables
| A table … | becomes |
|---|---|
| named like users, roles, permissions, sessions, tokens | Not migrated: people come from the identity provider |
| named like audit, history, revisions, versions, logs | Not migrated: archive |
| named like approvals, signatures, reviews | Not migrated: archive; the approver travels on the record |
| named like attachments, documents, files, uploads, with a file-like column or a reference | Gap: attachments |
| named like settings or parameters, with a key and a value column | Decision: candidates for options |
| consisting only of two or more references | Decision: many-to-many |
| named like schema_migrations, jobs, cache, notifications | Not migrated: technical |
| without a single-column primary key | Decision: left out until a key is named |
| anything else | A record type |
Columns of a record table
| A column … | becomes |
|---|---|
| the primary key, if a number or UUID | the old identifier (origin and cross-reference), not a field |
| the primary key, if a business key (a code) | the old identifier and a unique, required field |
approved_by, released_by, … / approved_at, … |
the origin's approver and date |
created_at, updated_by, version, … |
not carried: Countersign records these itself |
status or state with workflow values |
not carried, with a decision: export approved rows only |
deleted, is_active, archived, … |
not carried, with a decision: exclude or import and retire |
| a foreign key to a record table | a reference |
| a foreign key to a table that is not migrated | text holding the old identifier, to review |
with an enum type or CHECK (… IN (…)) |
a selection with those choices |
| integer / decimal / boolean / date types | integer / number / yes-no / date |
| a timestamp | a date; the time of day is dropped, to review |
| long text, JSON, XML | long text |
| binary or an array | not carried, with a decision |
NOT NULL becomes required, a unique constraint becomes unique. The record's name is the column called name,
title, label, code or similar, otherwise the first required text column.
Hierarchy. Of a table's required references, the one whose target lies deepest (a material's site, which itself
belongs to a supplier) becomes the parent; one more required reference to a flat list (a category) becomes the
group; all others are links. A table that refers to itself through a column that may be empty (a location within a
location) nests to any depth: that reference becomes an optional parent. A circle of parents across several tables
is broken and reported. These are the choices most worth a
person's look: they decide the tree and how options are inherited.
Roles. Three generic ones (editor, approver, administrator) so that the rehearsal can run. Replace them.
The source model format
For --model: what plan writes as source-model.json.
{
"tables": [
{
"name": "sites",
"from": "app/models.py:40",
"primaryKey": ["id"],
"rows": 5,
"columns": [
{ "name": "id", "type": "integer", "nullable": false },
{ "name": "supplier_id", "type": "integer", "nullable": false, "references": { "table": "suppliers", "column": "id" } },
{ "name": "name", "type": "varchar", "nullable": false, "length": 120, "unique": true },
{ "name": "kind", "type": "varchar", "nullable": false, "values": ["plant", "warehouse"] }
]
}
],
"problems": []
}
type is the database's type name in lower case; values are the allowed values of an enum or check; from says
where the definition was read, for the guide; rows is optional.
Limits
- The capability scan matches words in files. It misses what is named unusually and it finds words in comments. Its result is a list of questions with places to look, and the guide says so.
- The data model readers cover SQL definitions and Prisma schemas. Other forms need a database to look at or a model written by hand.
- The export reads SQLite and PostgreSQL directly; anything else through CSV files you export.
- One row of the old system becomes one record. Splitting or joining tables, or computing values, is done in the export (a view in the old database, or your own CSV files).
- The old audit trail, version history, users and attachments are not migrated, for the reasons given in Migration.
- The assistant is covered by requirements FR-MIG-09 to FR-MIG-14 and the tests in
lib/adopt/adopt.test.ts. The drafts it writes are proposals: your review, the rehearsal and the verification are what qualify a migration.