diff --git a/migrations/0180_decision_ledger.sql b/migrations/0180_decision_ledger.sql index f1f57d283e..a2651cd5e9 100644 --- a/migrations/0180_decision_ledger.sql +++ b/migrations/0180_decision_ledger.sql @@ -6,16 +6,10 @@ -- Every persistDecisionRecord write appends (including latest-finalize-wins rewrites of the same record id -- -- supersessions are deliberately VISIBLE history, not silent replacement). -- --- HONEST LIMIT (module header repeats this; see migrations/0195_decision_ledger_anchors.sql, #9267): a --- self-operated chain is tamper-EVIDENT against every actor except the operator, on its own. As of #9267, a --- scheduled job (src/review/ledger-anchor-scheduler.ts) additionally publishes a SIGNED, self-describing --- checkpoint of this chain's tip -- hourly, or every 256 new rows, whichever comes first -- to two places the --- operator does not control: a Sigstore Rekor transparency log and a git commit (cross-mirrored by GH Archive --- / Software Heritage the moment it's pushed). Rewriting history before the oldest still-referenced anchor --- now requires forging that signature or fabricating matching evidence at an external mirror too, not just --- editing this table. What remains open: the UNANCHORED TAIL since the last checkpoint is exactly as --- tamper-evident-only as before anchoring existed -- anchoring bounds how far back an undetected rewrite --- could reach, it does not make every row individually external-checkable in real time. +-- HONEST LIMIT (module header repeats this): a self-operated chain is tamper-EVIDENT, not tamper-PROOF -- +-- the operator can still rewrite wholesale. External anchoring (signed checkpoints / witness cosigning) is +-- the tracked follow-up once tenants exist, per the epic's sequencing. That gap does not reduce the value +-- against every OTHER actor, or against accidental corruption. CREATE TABLE IF NOT EXISTS decision_ledger ( seq INTEGER PRIMARY KEY, -- explicit, contiguous (verified); NOT autoincrement -- gaps are breaks record_id TEXT NOT NULL, -- decision_records.id at append time diff --git a/package.json b/package.json index 09965efc6d..7eaafd297c 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "control-plane:coverage": "node --experimental-strip-types scripts/control-plane-coverage.ts", "engine:coverage": "node --experimental-strip-types scripts/engine-coverage.ts", "db:migrations:check": "tsx scripts/check-migrations.ts", + "db:migrations:immutable:check": "tsx scripts/check-released-migrations-immutable.ts", "db:schema-drift:check": "tsx scripts/check-schema-drift.ts", "actionlint": "node --experimental-strip-types scripts/actionlint.ts", "lint:composite-actions": "node --experimental-strip-types scripts/lint-composite-actions.ts", @@ -131,7 +132,7 @@ "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts", "pretest:ci": "npm run check-node-version", - "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", + "test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", "test:release": "npm run test:ci && npm run changelog:check", "test:release:mcp": "npm run test:ci", "test:watch": "vitest", diff --git a/scripts/check-released-migrations-immutable.ts b/scripts/check-released-migrations-immutable.ts new file mode 100644 index 0000000000..c64d6d4528 --- /dev/null +++ b/scripts/check-released-migrations-immutable.ts @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// A migration that has shipped in a release is IMMUTABLE (#9420 regression). +// +// THE INCIDENT THIS EXISTS FOR: #9420 updated a doc COMMENT inside migrations/0180_decision_ledger.sql -- +// no DDL change at all -- and shipped it in orb-v3.5.0. But runSelfHostMigrations (src/selfhost/migrate.ts) +// records a sha256 of each applied migration's FULL TEXT and, on every boot, re-hashes the on-disk file and +// throws `selfhost_migration_content_drift` if it differs. Comments are part of that text. So the moment any +// already-upgraded deployment pulled the new image it would refuse to start -- not degrade, not warn: fail +// to boot, with the review pipeline down until a human restored the file. Nothing caught it: the existing +// db:migrations:check guards NUMBERING (collisions, gaps, filenames), and git reports a clean one-file diff +// because editing a file is not a conflict. +// +// The rule is therefore mechanical, and this is the check that enforces it: once a migration file exists in +// any released `orb-v*` tag, its bytes may never change again. Not the DDL, not a typo, not a comment. +// Forward-only means forward-only -- to change what a migration DID, add a new one; to change what it SAYS, +// put the prose in the source module that reads the table (see src/review/decision-record.ts's header for +// exactly this split). +// +// Deleting a released migration is likewise refused. migrate.ts tolerates a ledger row whose file has +// vanished (it skips unknown names), but a fresh deployment would then build a different schema than every +// existing one -- a silent divergence this check would rather stop at the PR. +import { execFileSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { MIGRATION_REBASELINE } from "./migration-rebaseline"; + +/** A released tag and the migration blobs it shipped, as `name -> blob sha`. */ +export type ReleasedTagManifest = { tag: string; files: ReadonlyMap }; + +export type MigrationViolation = { file: string; tag: string; kind: "modified" | "deleted" }; + +/** + * PURE core: any file whose blob sha differs from (or is missing versus) the one it was FIRST released with. + * + * The baseline is deliberately the EARLIEST tag that shipped each file, not every tag. A file's first + * release is when it froze: that is the content the oldest deployments applied and recorded a hash for, and + * they are both the most numerous and the ones with the most history to lose. Checking against every tag + * would be wrong here for a concrete reason -- when a released migration HAS been mutated (the #9420 + * incident), the released tags themselves disagree with each other, so no content could satisfy all of them + * and the check could never go green again, not even after the correct repair. + * + * `released` must be ordered oldest-first; first sighting of a file wins. + */ +export function findMutatedReleasedMigrations( + released: readonly ReleasedTagManifest[], + current: ReadonlyMap, +): MigrationViolation[] { + const frozen = new Map(); + for (const { tag, files } of released) { + for (const [file, blob] of files) if (!frozen.has(file)) frozen.set(file, { tag, blob }); + } + // Files edited before this guard existed are re-frozen at their current content instead of their first + // release -- see migration-rebaseline.ts for why that is safe here and why the table must never grow. + for (const [file, blob] of MIGRATION_REBASELINE) { + const existing = frozen.get(file); + if (existing) frozen.set(file, { tag: existing.tag, blob }); + } + + const violations: MigrationViolation[] = []; + for (const [file, { tag, blob }] of frozen) { + const currentBlob = current.get(file); + if (currentBlob === undefined) violations.push({ file, tag, kind: "deleted" }); + else if (currentBlob !== blob) violations.push({ file, tag, kind: "modified" }); + } + return violations.sort((a, b) => a.file.localeCompare(b.file)); +} + +function git(...args: string[]): string { + return execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); +} + +/** `migrations/*.sql` at a given rev, as `name -> blob sha`. */ +function migrationsAt(rev: string): Map { + const files = new Map(); + for (const line of git("ls-tree", "-r", rev, "--", "migrations/").split("\n")) { + // ` blob \t` + const match = /^\d+ blob ([0-9a-f]+)\t(migrations\/.+\.sql)$/.exec(line); + if (match?.[1] && match[2]) files.set(match[2].slice("migrations/".length), match[1]); + } + return files; +} + +/** Released ORB tags, oldest first, so the earliest tag to freeze a file is the one reported. */ +export function releasedOrbTags(): string[] { + return git("tag", "-l", "orb-v*", "--sort=creatordate").split("\n").filter(Boolean); +} + +/** + * The migrations as they exist ON DISK, hashed with git's own blob algorithm so they compare directly + * against `ls-tree` output. + * + * Deliberately NOT `ls-tree HEAD`: that reads the committed tree and is blind to uncommitted edits, so the + * check would go green locally on exactly the change it exists to reject and only fail later in CI. Reading + * the working tree makes it usable as a pre-commit check and makes what it reports match what the author is + * actually about to ship. + */ +function migrationsOnDisk(): Map { + const files = new Map(); + const names = readdirSync("migrations").filter((name) => name.endsWith(".sql")).sort(); + if (names.length === 0) return files; + // One batched hash-object call: 200 separate spawns is the difference between instant and noticeable. + const hashes = git("hash-object", "--", ...names.map((name) => join("migrations", name))).split("\n").filter(Boolean); + names.forEach((name, index) => { + const hash = hashes[index]; + if (hash) files.set(name, hash); + }); + return files; +} + +function main(): void { + const tags = releasedOrbTags(); + if (tags.length === 0) { + // A shallow clone or a fork with no tags cannot evaluate this rule. Say so rather than passing silently: + // a check that quietly becomes a no-op is how the thing it guards comes back. + console.error("released-migrations-immutable: no orb-v* tags visible — fetch tags (`git fetch --tags`) so this check can run."); + process.exit(1); + } + const released = tags.map((tag) => ({ tag, files: migrationsAt(tag) })); + const violations = findMutatedReleasedMigrations(released, migrationsOnDisk()); + + if (violations.length > 0) { + console.error("A migration that already shipped in a release was changed. Released migrations are immutable:\n"); + for (const { file, tag, kind } of violations) { + console.error(` migrations/${file} — ${kind} (first released in ${tag})`); + } + console.error( + "\n Every deployment that already applied one of these recorded a sha256 of its FULL text (comments\n" + + " included). src/selfhost/migrate.ts re-hashes on every boot and throws selfhost_migration_content_drift\n" + + " on a mismatch, so shipping this would make every already-upgraded ORB FAIL TO BOOT.\n\n" + + " To change what a migration DID: add a new migrations/NNNN_*.sql.\n" + + " To change what it SAYS: put the prose in the source module that reads the table, not the .sql.\n" + + " To undo an accidental edit: git checkout -- migrations/", + ); + process.exit(1); + } + const frozen = new Set(released.flatMap(({ files }) => [...files.keys()])).size; + console.log(`released-migrations-immutable: OK — ${frozen} released migration(s) unchanged across ${tags.length} orb-v tag(s).`); +} + +if (process.argv[1]?.endsWith("check-released-migrations-immutable.ts")) main(); diff --git a/scripts/migration-rebaseline.ts b/scripts/migration-rebaseline.ts new file mode 100644 index 0000000000..be3bd38c14 --- /dev/null +++ b/scripts/migration-rebaseline.ts @@ -0,0 +1,56 @@ +// Released migrations that were ALREADY edited before the immutability guard existed (#9420 fallout). +// +// check-released-migrations-immutable.ts normally freezes a migration at the content of the first release +// that shipped it. These 35 files cannot use that baseline: each was edited at some point across the project's +// history -- mostly in the orb-v0.1.0 / orb-v0.4.0 era -- long before the rule was written down or enforced. +// +// They are RE-FROZEN here at their current content rather than exempted. Exempting them would leave 35 +// permanent holes in a guard whose whole value is having none; re-baselining keeps every one of them under +// the rule from here on, and costs only this generated table. Verified when this landed: none of these drifts +// against the live fleet -- every edit predates the deployments that applied the file, so no running ORB +// recorded the older hash. That is what makes re-freezing safe rather than merely convenient. +// +// NOTE 0180_decision_ledger.sql is deliberately NOT here. It is the file the #9420 incident actually broke, +// and it was repaired by restoring its original bytes -- so its first-release baseline is correct and it +// stays under the normal rule. +// +// DO NOT ADD ENTRIES. A new entry means someone edited a released migration and papered over it, which is +// precisely the boot failure this guard exists to prevent. Add a new migrations/NNNN_*.sql instead, and put +// prose in the source module that reads the table (see src/review/decision-record.ts's header). +export const MIGRATION_REBASELINE: ReadonlyMap = new Map([ + ["0029_ai_review_provider_model.sql", "e7533a461476ea7fecebc33cc6a7889295add479"], + ["0034_slop_ai_advisory.sql", "ab23e28623a484c93f045bc752407fbab2044e98"], + ["0035_pull_request_slop_assessment.sql", "56d78b31ff660e3a99c92208fbd5d9ac94fb53da"], + ["0042_agent_autonomy.sql", "85c28edacc98c92cc625c6371f5a29327577ca58"], + ["0047_self_improve_tunables.sql", "af6347526cff858922f7adfa1c32141cfb0a954b"], + ["0049_review_audit_parity.sql", "ebdcbcc7b65dd4fb25d96c5792d790330ef358b6"], + ["0050_review_targets.sql", "4f75b205186fd7818d29eb7a11a97b3e995d303e"], + ["0051_repo_chunks.sql", "7e1405a4742ccc3380ea12e70b6f9ad53ec63006"], + ["0052_pr_merge_attempt_terminal.sql", "7dc06935c33848233c15ade0a9df0cebef379630"], + ["0056_orb_events.sql", "daec50cef4efe09fa810f771963dd5d78a9cf94c"], + ["0057_orb_installations.sql", "e7c96f4500ff3e0f1bdd7811db11c682ae13f263"], + ["0058_orb_signals.sql", "796cd598d416adeebd8e3f5c4f0b8b48247b6424"], + ["0060_orb_fleet_collector.sql", "47a4d7756a5bbd8ab68490a9d2262004f1982d45"], + ["0061_orb_instances.sql", "151081374a2f7e1bb4f8e97e2978c06f7ea1f9ff"], + ["0062_pr_last_regated_at.sql", "d3bd6140263884b61aed73d144fc2179bef74c90"], + ["0064_orb_webhook_events.sql", "b8e60650f6986c0b67bd10543785c771c0e3be31"], + ["0065_orb_github_installations.sql", "1607d818eca85ce8ec561f6d8504ef15c8d5aad7"], + ["0067_orb_pr_outcomes.sql", "89eee98d18e50cce3dd1b874f0a12fc92fb406aa"], + ["0068_orb_enrollments.sql", "976aea26f676c9fcbe876a9389e7ee45db9bf46f"], + ["0071_installations_app_id.sql", "698697c7609dfea8ff42aadca2341705d4fbf9a8"], + ["0072_contributor_blacklist.sql", "626b6580274e8633f4bf3e7bf9cea1929128aef2"], + ["0080_pr_last_published_surface_sha.sql", "4fcca720632b83fbfdb6c9a57f1db156103d11c6"], + ["0091_review_nag_cooldown.sql", "924cde088aff88d0fe2b0d2bf93a3c510ecc1f82"], + ["0097_command_rate_limit.sql", "284cc6fffd0c3d079d43ea3cd5b2149fa4da5396"], + ["0100_review_nag_monitored_mentions.sql", "793664ddeb6c859fbafae573cf1a89ff20c55283"], + ["0102_fix_linked_issue_gate_mode_default.sql", "893273a3e6afba06f65bdf185e09a93cfc8566cc"], + ["0113_review_evasion_protection.sql", "61e5a23fcc1e377cb0bd2b01e5a31300cf1e43f9"], + ["0116_regate_sweep_order_mode.sql", "f5824a872f616d2d990d5ff2a4b8896cdbab1644"], + ["0119_ai_slop_cache.sql", "3fe8ad831ae0754c9a441998912d603e482707af"], + ["0126_contributor_gate_history.sql", "d8c7714ed65ae0845fcdb62bb68a3c06d4fb9f5f"], + ["0127_agent_global_freeze_override.sql", "78169578f5b45a0e2dd225781b562ca325ac404a"], + ["0134_pr_last_backlog_convergence_regated_at.sql", "ea71b7236b303a6b21cac8b0684adc969f629d13"], + ["0137_predicted_gate_calls.sql", "5a35dfc8ae2ceb8711a639daf59f2a59d87ffe67"], + ["0140_ai_review_low_confidence_disposition.sql", "bbe9077f0244d2d850aa392594272781e34860bd"], + ["0143_repository_skip_automation_bot_authors.sql", "36c139f3be5b48c43d40506080e1d536750d408c"], +]); diff --git a/src/review/decision-record.ts b/src/review/decision-record.ts index a2ed02838b..3584396a92 100644 --- a/src/review/decision-record.ts +++ b/src/review/decision-record.ts @@ -12,7 +12,12 @@ // (#8838) consume — one schema, three consumers, so drift between "what we published" and "what we can // replay" is structurally impossible. // -// HONEST LIMIT (#9122, mirrored from migrations/0180_decision_ledger.sql's own header): the hash-chained +// HONEST LIMIT (#9122). This is the CURRENT statement of it, and the only one that gets updated: +// migrations/0180_decision_ledger.sql carries an older, shorter version in its own header and must keep it +// verbatim forever. That file has already been applied on running deployments, and runSelfHostMigrations +// hashes every applied migration's FULL text (comments included) to detect post-apply edits -- so editing +// its prose, even harmlessly, makes every already-upgraded ORB fail to boot. Documentation about this table +// belongs here, where it can change freely. The hash-chained // ledger below makes this instance's history tamper-EVIDENT against every actor except an operator with // direct DB access, on its own — such an operator could still rewrite the chain wholesale (delete every row, // recompute a fresh one from genesis) and nothing INTERNAL to this table can detect that from first diff --git a/test/unit/check-released-migrations-immutable.test.ts b/test/unit/check-released-migrations-immutable.test.ts new file mode 100644 index 0000000000..ba51582fb0 --- /dev/null +++ b/test/unit/check-released-migrations-immutable.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { findMutatedReleasedMigrations, type ReleasedTagManifest } from "../../scripts/check-released-migrations-immutable"; +import { MIGRATION_REBASELINE } from "../../scripts/migration-rebaseline"; + +// #9420: a doc-comment edit inside migrations/0180_decision_ledger.sql shipped in orb-v3.5.0. Because +// src/selfhost/migrate.ts hashes each applied migration's FULL text and throws on a post-apply mismatch, +// that comment made every already-upgraded ORB fail to BOOT. Caught by comparing the live server's +// _selfhost_migrations ledger against the repo before the next image was cut. These pin the guard. + +const tag = (name: string, files: Record): ReleasedTagManifest => ({ tag: name, files: new Map(Object.entries(files)) }); + +describe("findMutatedReleasedMigrations (#9420)", () => { + it("REGRESSION: reproduces the real failure — a released migration whose content changed", () => { + const violations = findMutatedReleasedMigrations( + [tag("orb-v3.5.0-beta.5", { "0180_decision_ledger.sql": "blob-original" })], + new Map([["0180_decision_ledger.sql", "blob-edited"]]), + ); + expect(violations).toEqual([{ file: "0180_decision_ledger.sql", tag: "orb-v3.5.0-beta.5", kind: "modified" }]); + }); + + it("passes once the file is restored — the fix, not just the detection", () => { + const violations = findMutatedReleasedMigrations( + [tag("orb-v3.5.0-beta.5", { "0180_decision_ledger.sql": "blob-original" })], + new Map([["0180_decision_ledger.sql", "blob-original"]]), + ); + expect(violations).toEqual([]); + }); + + it("freezes at the FIRST release, so a later tag that disagrees cannot move the baseline", () => { + // This is the #9420 shape exactly: beta.5 shipped the original, 3.5.0 shipped the edit. Anchoring on the + // earliest tag is what lets the correct repair (restore the original) ever go green again -- checking + // against every tag would make the two releases contradict each other permanently. + const violations = findMutatedReleasedMigrations( + [ + tag("orb-v3.5.0-beta.5", { "0180_decision_ledger.sql": "blob-original" }), + tag("orb-v3.5.0", { "0180_decision_ledger.sql": "blob-edited" }), + ], + new Map([["0180_decision_ledger.sql", "blob-original"]]), + ); + expect(violations).toEqual([]); + }); + + it("REGRESSION: deleting a released migration is refused too", () => { + const violations = findMutatedReleasedMigrations([tag("orb-v1.0.0", { "0007_thing.sql": "blob" })], new Map()); + expect(violations).toEqual([{ file: "0007_thing.sql", tag: "orb-v1.0.0", kind: "deleted" }]); + }); + + it("a NEW, never-released migration is free to change", () => { + const violations = findMutatedReleasedMigrations( + [tag("orb-v1.0.0", { "0007_thing.sql": "blob" })], + new Map([ + ["0007_thing.sql", "blob"], + ["0008_brand_new.sql", "anything-at-all"], + ]), + ); + expect(violations).toEqual([]); + }); + + it("reports every violation, sorted, not just the first", () => { + const violations = findMutatedReleasedMigrations( + [tag("orb-v1.0.0", { "0009_b.sql": "x", "0008_a.sql": "x", "0010_c.sql": "x" })], + new Map([ + ["0009_b.sql", "changed"], + ["0008_a.sql", "changed"], + ["0010_c.sql", "x"], + ]), + ); + expect(violations.map((violation) => violation.file)).toEqual(["0008_a.sql", "0009_b.sql"]); + }); + + it("no released tags means nothing is frozen — an empty repo is not a violation", () => { + expect(findMutatedReleasedMigrations([], new Map([["0001_x.sql", "blob"]]))).toEqual([]); + }); +}); + +describe("MIGRATION_REBASELINE", () => { + it("INVARIANT: every rebaselined file still exists and is still released", async () => { + // A stale entry is worse than none: it silently exempts a filename that no longer means anything, and + // (per validate-no-hand-written-js's stale-path lesson) a watched path that quietly stops existing is + // how a guard rots. Fails here rather than degrading in silence. + const { readdirSync } = await import("node:fs"); + const onDisk = new Set(readdirSync("migrations")); + for (const file of MIGRATION_REBASELINE.keys()) expect({ file, present: onDisk.has(file) }).toEqual({ file, present: true }); + }); + + it("INVARIANT: the table does not grow — a new entry means someone papered over a boot-breaking edit", () => { + // Pinned to the count generated when the guard landed. Raising this number is not a merge conflict to + // resolve; it means the change under review edits a released migration and must be done differently. + expect(MIGRATION_REBASELINE.size).toBe(35); + }); + + it("re-freezes rather than exempts: a rebaselined file that changes AGAIN is still caught", () => { + const [file] = [...MIGRATION_REBASELINE.keys()]; + expect(file).toBeDefined(); + const frozenBlob = MIGRATION_REBASELINE.get(file!); + const violations = findMutatedReleasedMigrations( + [tag("orb-v0.1.0-beta.1", { [file!]: "the-original-pre-edit-blob" })], + new Map([[file!, "a-brand-new-edit"]]), + ); + expect(violations).toHaveLength(1); + expect(frozenBlob).not.toBe("a-brand-new-edit"); + }); +});