On this page

Use it · docs/migration-assistant.md

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

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]
  1. Checks config.json and mapping.json against each other.
  2. Creates a fresh SQLite store in rehearsal/ and initialises it with the configuration.
  3. 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).
  4. Reads the store back, checks the hash chain, replays every entry, and compares every imported record with its source row (how).
  5. 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.