Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions migrations/0180_decision_ledger.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions scripts/check-released-migrations-immutable.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> };

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<string, string>,
): MigrationViolation[] {
const frozen = new Map<string, { tag: string; blob: string }>();
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<string, string> {
const files = new Map<string, string>();
for (const line of git("ls-tree", "-r", rev, "--", "migrations/").split("\n")) {
// `<mode> blob <sha>\t<path>`
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<string, string> {
const files = new Map<string, string>();
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 <tag> -- migrations/<file>",
);
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();
56 changes: 56 additions & 0 deletions scripts/migration-rebaseline.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = 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"],
]);
7 changes: 6 additions & 1 deletion src/review/decision-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading