The most dangerous migration is not the one that fails; it is the one somebody edited after it already ran, because every migration framework worth using records applied versions in a table and then refuses to touch them again, which means a quiet fix-up commit to 003_add_sku.sql changes nothing on any environment that already applied it and changes everything on the one environment that has not, and you find out about the divergence three months later when a fresh staging build behaves differently from production and nobody can say which one is right. I have watched this exact movie in the Rails world, where schema_migrations stores a bare version number and the file contents are free to rot, and in the Flyway world, which at least had the decency to checksum the file and complain, so when we built the migration runner for MongrelDB Kit the checksum was never an optional extra; it is the load-bearing piece, and the interesting design decisions are all in what the checksum covers, what it deliberately does not, and when it gets verified.
What the checksum actually covers
A Kit migration is a small object with a version, a name, an optional declarative ops list, and an imperative up() callback that does the work:
await migrate(db, schema, [
{
version: 5,
name: 'tighten_products',
ops: [{ kind: 'addUnique', table: 'products', constraint: 'products_sku_uq' }],
async up({ kit }) {
await addUnique(kit, 'products', unique(['sku'], { name: 'products_sku_uq' }));
},
},
]);
The checksum is a SHA-256 over one canonical serialization of the version, the name, and the ordered op list, with the key order fixed and standard JSON string escaping, so there is exactly one byte string that represents a given logical migration:
sha256('{"version":<n>,"name":<json>,"ops":[<op>,...]}')
The canonical form matters more than the hash algorithm, because the same logical migration has to produce the identical checksum in TypeScript, in Rust, and in Python, and any serialization that leaves wiggle room, a space here, a reordered key there, breaks that property the moment two languages disagree about whitespace. There is a small two-layer wrinkle worth naming, since the discriminator in your TypeScript ops is kind while the canonical serialization calls it op, a renaming step in the canonicalizer (kind: 'addUnique' serializes as {"op":"add_unique",...}) that maps camelCase input onto the snake_case wire vocabulary every language shares, so the object you write and the bytes that get hashed are deliberately not identical. The conformance tests pin two known vectors so a drift in any language’s canonicalizer fails a build instead of failing a deploy: {"version":1,"name":"init","ops":[{"op":"create_table","name":"users"}]} hashes to fe2f521793591207bd4d8645c2631e4b7ce43e30fe7ea5691a2846c74ea71cc3, and the empty form {"version":1,"name":"init","ops":[]} hashes to 6408373a4372a2c49859db2a4548ea43308e5ba7dd3609998ca376606cf09757, and both are asserted byte-for-byte on both sides of the language boundary. When ops is omitted, as it often is in TypeScript where the list is metadata rather than an execution plan, the checksum covers the version and name against an empty op list, so even the smallest possible migration is content-addressed.
When the checksum gets verified
The runner records each applied migration in the internal __kit_schema_migrations table, one row carrying the version, the name, the checksum, the applied timestamp, the kit version that ran it, and a status, and that record is written in the same transaction discipline as everything else the kit does. The part that earns the word “content-addressed” is what happens on every subsequent run: before computing the pending set, the runner recomputes the checksum of every supplied migration that claims to correspond to an already-applied record and compares it, and the name, against what is stored, and any mismatch, or any applied version that has vanished from the supplied list entirely, raises a KitSchemaDriftError and stops the run:
migration 5 (tighten_products) checksum mismatch: stored a1b2..., expected c3d4...
That ordering is the whole point, because drift detection happens before any new migration is applied, so a tampered history aborts the deploy while the database is still untouched rather than halfway through a run of new DDL on top of a history nobody trusts. The pending set itself is computed from a high-water mark, meaning only versions above the maximum already applied are eligible to run, which is what makes re-running the runner a no-op and what makes the classic renumbering attack, slipping a new file in as 003b and hoping it replays, a non-event: a renumbered migration either sits below the high-water mark and never runs, or it collides with an applied record whose checksum it cannot reproduce, and either way the runner says so. Records left in failed status are deliberately skipped by the drift check, and the high-water mark counts only versions that reached applied, which means a failed migration sits below the watermark and blocks itself and everything after it until you repair or remove the record; that is the intended posture, because a failed run leaves the history genuinely ambiguous and the runner refuses to guess its way forward.
The honest tradeoff
The cost of this design is that the checksum is only as faithful as the ops you declare, and this is the seam I would point at before recommending it to anyone: in the TypeScript runner the ops array never drives the work, your up() does, so if you list an addUnique op and then quietly do something extra inside the callback, the checksum certifies the list and not the deed, and the discipline the system actually demands is that you keep the two in sync so the audited description stays content-aware. I consider that a fair price, and the comparison that settles it for me is the old PHP habit of a migrations folder full of numbered SQL files that everyone knew better than to edit but everyone edited anyway, where the only enforcement was social and the social contract lost to the first hotfix under deadline; the modern equivalent of doing it right is a checksum computed from a canonical form, stored next to the version, and re-verified on every run before anything else is allowed to happen, so the boring discipline that used to live in a code-review comment now lives in the runner, and the runner does not get tired on Fridays.