From 94b8efd75f939d01d0e1b866ae3ac2bbec66d607 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 05:00:41 +0000 Subject: [PATCH 1/7] test(drift): diff what the migration chain builds against schema.sql MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI proves two adjacent things and neither of them is this one. `supabase migration up --local` proves the migration chain APPLIES, and the drift manifest's sha256 check proves the mirror is not stale. Nothing compares the RESULT of the chain against supabase/schema.sql, so a migration whose function body or policy predicate diverges from the mirror passes every pre-merge gate. That is not theoretical: on 2026-09-01 migration 20260831100000 (PR #2477) redefined public.correct_clinical_query_terms(text,real) and schema.sql was never updated to match. Every pre-merge gate stayed green and the post-merge live-drift alarm went red on main. Adds scripts/check-chain-mirror-parity.ts, which compares two captured schema_drift_snapshot() payloads. It reuses compareDriftSnapshots() from check-drift.ts rather than reimplementing a comparator — two comparators would eventually disagree about what "different" means, and then one would be wrong. migration_history is stripped from both sides by construction, not allowlisted: a schema.sql replay has no supabase_migrations schema, so that category can only produce noise here and stays the live gate's business. Wired into the CI Migration replay job. The mirror side is the drift manifest that job has just regenerated from this PR's schema.sql, so no second replay is built. That does leave the two sides on different images (the Supabase emulator vs the pinned bare supabase/postgres), and the header of the script says so: building both sides identically was tried and does not work, because a mirror database created inside the emulator has no `auth` schema for schema.sql's `references auth.users(id)` columns. REPORT-ONLY on landing, deliberately. The divergences that already exist cannot be enumerated offline — docs/database-drift-detection.md backlog item 10 records ~13 chain-vs-mirror keys plus storage buckets only schema.sql creates — so this prints them rather than blocking on a set nobody has measured. The follow-up commits supabase/chain-mirror-allowlist.json from the first real run's summary, adds --strict, and drops the continue-on-error lines in the same change; tests/chain-mirror-parity.test.ts pins those two facts to each other so they cannot drift apart. The parity allowlist is a separate file from supabase/drift-allowlist.json and must stay separate: an entry there blinds the weekly live-drift alarm, which is a different and far more consequential thing to go blind about. A test asserts the live allowlist still carries only migration_history entries. Registers the script in dbPatterns so a change to the parity checker routes db_changed and runs the job it belongs to, and in test:ci-workflows, which an existing contract test requires of any suite that reads a workflow file. No migration, no SQL applied to any database, supabase/schema.sql untouched. Verified offline: 19 new tests (comparison directions, the 2026-09-01 mismatch class, policy/index shapes, fail-closed allowlist, report wording, CI wiring); the report-only/tolerance tie proven to fail when the two are separated; script --self-test; full unit suite 948 files / 12074 tests passed; lint, typecheck, check:migration-role, check:github-actions, check:gate-manifest, check:ci-scope, check:knip, docs:check-inventory, docs:check-scripts all green. NOT verified: the CI steps themselves. This container has no Docker daemon, no Supabase CLI, and a PostgreSQL without the vector extension, so neither replay can be built here; db-reset-verify is also skipped on draft PRs. Their first real execution is on an undrafted PR. Refs #QCNE6N Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sjekpEw82gMp57C8xzSxZ --- .github/workflows/ci.yml | 48 ++++ docs/scripts-index.md | 5 +- package.json | 3 +- scripts/check-chain-mirror-parity.ts | 362 +++++++++++++++++++++++++++ scripts/ci-change-scope.mjs | 2 +- supabase/chain-mirror-allowlist.json | 6 + tests/chain-mirror-parity.test.ts | 256 +++++++++++++++++++ 7 files changed, 678 insertions(+), 4 deletions(-) create mode 100644 scripts/check-chain-mirror-parity.ts create mode 100644 supabase/chain-mirror-allowlist.json create mode 100644 tests/chain-mirror-parity.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09554b1f1f..237cab4d4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1094,6 +1094,54 @@ jobs: } NODE + # #QCNE6N. The step above proves the manifest is not stale and `supabase + # migration up --local` proved the chain APPLIES. Neither compares the + # RESULT of the chain against supabase/schema.sql, so a migration whose + # function body or policy predicate diverges from the mirror passes every + # pre-merge gate — which is how correct_clinical_query_terms(text,real) + # reached main red on 2026-09-01, caught only by the post-merge live-drift + # alarm. + # + # The mirror side is the manifest `drift:manifest` just regenerated from + # THIS PR's schema.sql, so no second replay is built here. That does mean + # the two sides come from different images (emulator vs the pinned bare + # supabase/postgres) and some reported difference will be platform + # provenance — see the header of scripts/check-chain-mirror-parity.ts. + # + # REPORT-ONLY on purpose. The divergences that already exist cannot be + # enumerated offline (docs/database-drift-detection.md backlog item 10 + # records ~13 chain-vs-mirror keys plus schema.sql-only storage buckets), + # so this lands printing them rather than blocking on a set nobody has + # measured. The follow-up commits supabase/chain-mirror-allowlist.json from + # this job's summary, adds `--strict`, and drops the `continue-on-error` + # lines below in the same change. tests/chain-mirror-parity.test.ts pins + # those two facts to each other so they cannot drift apart. + - name: Capture the migration chain's schema snapshot + id: chain-snapshot + continue-on-error: true + run: | + set -euo pipefail + db_container="$(docker ps --format '{{.Names}}' | grep '^supabase_db_' | head -n 1)" + test -n "${db_container}" + docker exec -i "${db_container}" \ + psql -U postgres -d postgres -tA -v ON_ERROR_STOP=1 \ + -c 'select public.schema_drift_snapshot()::text;' > /tmp/chain-snapshot.json + test -s /tmp/chain-snapshot.json + + - name: Compare the migration chain against supabase/schema.sql + id: chain-mirror-parity + continue-on-error: true + if: steps.chain-snapshot.outcome == 'success' + run: | + npm run check:chain-mirror-parity -- \ + --chain /tmp/chain-snapshot.json \ + --mirror-manifest supabase/drift-manifest.json + + - name: Report a missing chain/mirror parity capture + if: steps.chain-snapshot.outcome != 'success' + run: | + echo "::warning::chain/mirror parity did not run - the chain snapshot capture failed. It is report-only today so it does not block the merge, but it means the comparison produced no evidence." + - name: Upload regenerated drift manifest if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/docs/scripts-index.md b/docs/scripts-index.md index b141994388..408842b6b7 100644 --- a/docs/scripts-index.md +++ b/docs/scripts-index.md @@ -1,6 +1,6 @@ # Scripts index -Curated map of `scripts/` (283 files) and the `package.json` script surface (284 entries), +Curated map of `scripts/` (284 files) and the `package.json` script surface (285 entries), grouped by purpose. This is orientation, not an exhaustive per-file listing — the authoritative command list is `package.json`, and `npm run docs:check-scripts` verifies every `npm run ` referenced in docs resolves to a real script. `npm run docs:update` refreshes the exact counts above. @@ -90,7 +90,8 @@ For executable phone-chrome changes, use `verify:phone-chrome` before the broad `check-document-label-governance.ts`, `promote-public-documents-batch.ts`, `audit-public-document-approvals.ts`, `production-readiness.ts`, `check-supabase-project.ts`, `check-default-acl.ts`, `check-drift.ts`, `generate-drift-manifest.ts`, -`check-migration-history-alignment.ts`. +`check-migration-history-alignment.ts`, `check-chain-mirror-parity.ts` (offline — compares two +captured `schema_drift_snapshot()` payloads, never contacts a database). ## RAG evaluation [live] diff --git a/package.json b/package.json index fa3799198c..e910abcd36 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "test:coverage": "node scripts/run-vitest.mjs run --coverage", "test:coverage:node": "node scripts/run-vitest.mjs run --project=node --coverage", "test:coverage:ui": "node scripts/run-vitest.mjs run --project=jsdom --coverage", - "test:ci-workflows": "node scripts/run-vitest.mjs run tests/ci-cache-safety.test.ts tests/authenticated-live-workflow.test.ts tests/codex-autofix-workflow.test.ts tests/codex-run-pr-operator-workflow.test.ts tests/eval-canary-workflow.test.ts tests/live-drift-workflow.test.ts tests/ops-digest.test.ts tests/container-ci-contract.test.ts tests/test-runner-safety.test.ts tests/installed-lock-parity.test.ts tests/railway-config.test.ts tests/ingestion-autopilot.test.ts tests/ingestion-autopilot-workflow.test.ts tests/check-lighthouse-budget.test.ts tests/live-web-vitals-inputs.test.ts tests/offline-release-profile.test.ts", + "test:ci-workflows": "node scripts/run-vitest.mjs run tests/ci-cache-safety.test.ts tests/authenticated-live-workflow.test.ts tests/chain-mirror-parity.test.ts tests/codex-autofix-workflow.test.ts tests/codex-run-pr-operator-workflow.test.ts tests/eval-canary-workflow.test.ts tests/live-drift-workflow.test.ts tests/ops-digest.test.ts tests/container-ci-contract.test.ts tests/test-runner-safety.test.ts tests/installed-lock-parity.test.ts tests/railway-config.test.ts tests/ingestion-autopilot.test.ts tests/ingestion-autopilot-workflow.test.ts tests/check-lighthouse-budget.test.ts tests/live-web-vitals-inputs.test.ts tests/offline-release-profile.test.ts", "test:cc-guards": "node scripts/run-vitest.mjs run --reporter=dot tests/caring-contacts-plan-draft.dom.test.tsx tests/caring-contacts-plan-patient-detail.test.ts tests/caring-contacts-plan-activation.test.ts tests/caring-contacts-plan-wizard.dom.test.tsx tests/caring-contacts-schedule.test.ts tests/caring-contacts-schedule-view.test.ts tests/caring-contacts-schedule-route.test.ts tests/caring-contacts-schedule-screen.dom.test.tsx tests/caring-contacts-schedule-page.dom.test.tsx tests/caring-contacts-clock.test.ts tests/caring-contacts-new-plan-page.dom.test.tsx tests/caring-contacts-explained-automation.dom.test.tsx tests/caring-contacts-workspace-shell.dom.test.tsx tests/caring-contacts-patients-directory.dom.test.tsx tests/caring-contacts-patient-overview.dom.test.tsx tests/caring-contacts-patients-page.dom.test.tsx tests/caring-contacts-domain-isolation.test.ts tests/caring-contacts-interface-vocabulary.test.ts tests/caring-contacts-retention.test.ts tests/caring-contacts-repository.test.ts tests/caring-contacts-overlay-definitions.test.ts tests/caring-contacts-overlay-trigger-inventory.test.ts tests/caring-contacts-workspace-screens.test.ts tests/route-reachability.test.ts tests/design-system-adoption.test.ts tests/caring-contacts-contact-time-adjustment.dom.test.tsx tests/caring-contacts-contact-route.test.ts tests/caring-contacts-overlay-trigger.dom.test.tsx tests/caring-contacts-overlay-host.dom.test.tsx tests/source-control-bytes.test.ts tests/caring-contacts-demo-seed.test.ts tests/caring-contacts-pathway-versions.test.ts tests/caring-contacts-templates-library.dom.test.tsx tests/caring-contacts-templates-page.dom.test.tsx tests/caring-contacts-template-detail.dom.test.tsx tests/caring-contacts-template-detail-page.dom.test.tsx tests/caring-contacts-reporting.test.ts tests/caring-contacts-guidance-reports-pages.dom.test.tsx tests/caring-contacts-team-workload.test.ts tests/caring-contacts-team-route.test.ts tests/caring-contacts-team-roster.dom.test.tsx tests/caring-contacts-team-page.dom.test.tsx", "test:e2e": "node scripts/run-playwright.mjs", "test:e2e:all": "node scripts/run-playwright.mjs", @@ -284,6 +284,7 @@ "arbiter:status": "node scripts/gate-arbiter.mjs status", "arbiter:clear": "node scripts/gate-arbiter.mjs clear", "check:drift": "node scripts/run-tsx.mjs scripts/check-drift.ts", + "check:chain-mirror-parity": "node scripts/run-tsx.mjs scripts/check-chain-mirror-parity.ts", "check:migration-history": "node scripts/run-tsx.mjs scripts/check-migration-history-alignment.ts", "drift:manifest": "node scripts/run-tsx.mjs scripts/generate-drift-manifest.ts", "sync:pr-branches": "node scripts/sync-open-pr-branches.mjs", diff --git a/scripts/check-chain-mirror-parity.ts b/scripts/check-chain-mirror-parity.ts new file mode 100644 index 0000000000..fbd8408ae7 --- /dev/null +++ b/scripts/check-chain-mirror-parity.ts @@ -0,0 +1,362 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +import { compareDriftSnapshots, type AllowlistEntry, type Finding } from "./check-drift"; + +/** + * check:chain-mirror-parity — does the migration chain actually BUILD what + * supabase/schema.sql describes? + * + * CI already proves two adjacent things and neither of them is this one: + * `supabase migration up --local` the chain APPLIES without erroring; + * committed vs generated sha256 the drift manifest is not stale. + * Nothing compares the RESULT of replaying the chain against the mirror. A + * migration whose function body, policy predicate or index shape diverges from + * schema.sql therefore passes every pre-merge gate — which is not theoretical: + * on 2026-09-01 `public.correct_clinical_query_terms(text,real)` diverged that + * way (migration 20260831100000, PR #2477) and only the post-merge live-drift + * alarm caught it, red on main. + * + * This compares two `public.schema_drift_snapshot()` payloads captured from two + * databases built the two different ways, and reports the difference. + * + * --chain snapshot from the database the migration chain built + * --mirror snapshot from a database supabase/schema.sql built + * --mirror-manifest supabase/drift-manifest.json, whose `snapshot` IS + * that mirror side (generated by `npm run drift:manifest`) + * --allowlist default supabase/chain-mirror-allowlist.json + * --summary append a Markdown report (GITHUB_STEP_SUMMARY) + * --strict exit 1 on any divergence that is not allowlisted + * --self-test offline verification of the comparison wiring + * + * KNOWN ASYMMETRY, and the reason this starts report-only rather than blocking. + * The chain side is the Supabase local emulator's database; the mirror side is + * `drift:manifest`'s replay into the pinned bare `supabase/postgres` image plus + * scripts/sql/drift-replay-scaffold.sql. Two different images, so some reported + * difference will be platform provenance (extension sets, the storage schema, + * role ACL arrays) rather than a real chain-vs-mirror divergence. Building both + * sides identically would be better, but a mirror database created inside the + * emulator does not inherit the `auth` schema that supabase/schema.sql needs for + * its `references auth.users(id)` columns, and reproducing the emulator's chain + * inside the bare image means driving 224 migrations by hand rather than through + * `supabase migration up`. Reusing the manifest replay keeps the mirror side on + * tooling that is already proven in this job; the asymmetry it costs is what the + * allowlist is for, and the first real CI run is what measures it. + * + * WITHOUT `--strict` this is REPORT-ONLY: it prints every divergence and exits + * 0. That is deliberate for its first landing — the existing divergences cannot + * be enumerated offline (see docs/database-drift-detection.md backlog item 10, + * which already records ~13 chain-vs-mirror keys plus storage buckets that only + * schema.sql creates), so the honest sequence is: land report-only, read the + * first real CI run, commit the allowlist that run prints, then add `--strict`. + * + * The comparison itself is `compareDriftSnapshots` from check-drift.ts — the + * same function the live gate uses. Two comparators would eventually disagree + * about what "different" means, and then one of them would be wrong. + * + * `migration_history` is stripped from both sides by construction, not by + * allowlist: a schema.sql replay has no `supabase_migrations` schema at all, so + * that category can only ever produce noise here. It stays the live gate's + * business. + * + * This never contacts a database. Both snapshots are captured by the caller. + */ + +export type ParityAllowlistEntry = { + category: string; + kind: Finding["kind"]; + key: string; + reason: string; +}; + +const PARITY_KINDS = new Set(["missing_live", "unexpected_live", "mismatch"]); + +/** Categories `schema_drift_snapshot()` reports and this gate compares. */ +export const PARITY_CATEGORIES = [ + "extensions", + "tables", + "views", + "functions", + "indexes", + "policies", + "constraints", + "triggers", + "storage_buckets", +] as const; + +/** + * Structural validation of one allowlist entry. A malformed entry never matches + * a finding, so it can never silence one: the divergence stays reported and the + * entry is called out as stale. Same fail-closed shape as the live gate's + * `historyEntryProblems`. + */ +export function parityEntryProblems(entry: ParityAllowlistEntry): string[] { + const problems: string[] = []; + if (!PARITY_CATEGORIES.includes(entry.category as (typeof PARITY_CATEGORIES)[number])) { + problems.push(`category must be one of ${PARITY_CATEGORIES.join("|")}`); + } + if (!PARITY_KINDS.has(entry.kind)) problems.push(`kind must be one of ${[...PARITY_KINDS].join("|")}`); + if (typeof entry.key !== "string" || entry.key.trim() === "") problems.push("key is required"); + if (typeof entry.reason !== "string" || entry.reason.trim().length <= 20) { + problems.push("reason must be a real explanation (> 20 chars)"); + } + return problems; +} + +/** + * The migration-history probe is live-only by design. Removing it from both + * sides here keeps `compareDriftSnapshots` from reporting the chain database's + * (entirely expected) `supabase_migrations` rows as parity findings. + */ +export function withoutHistoryProbe(snapshot: Record): Record { + const copy = { ...snapshot }; + delete copy.migration_history; + delete copy.migration_history_probe; + delete copy.captured_at; + return copy; +} + +export type ParityResult = { + findings: Finding[]; + allowed: { entry: ParityAllowlistEntry; finding: Finding }[]; + staleEntries: ParityAllowlistEntry[]; + infos: string[]; +}; + +/** + * Compare the two builds. `mirror` is the expectation (schema.sql is the + * committed description of the database) and `chain` is what the migrations + * actually produced, so a `missing_live` finding reads "schema.sql describes it; + * the chain never builds it" and `unexpected_live` reads the other way round. + */ +export function compareChainAgainstMirror( + mirror: Record, + chain: Record, + allowlist: ParityAllowlistEntry[], +): ParityResult { + const valid = allowlist.filter((entry) => parityEntryProblems(entry).length === 0); + const comparison = compareDriftSnapshots( + withoutHistoryProbe(mirror), + withoutHistoryProbe(chain), + valid as unknown as AllowlistEntry[], + ); + + const usedKeys = new Set( + comparison.allowed.map(({ finding }) => `${finding.category}|${finding.kind}|${finding.key}`), + ); + return { + findings: comparison.findings, + allowed: comparison.allowed.map(({ entry, finding }) => ({ + entry: entry as unknown as ParityAllowlistEntry, + finding, + })), + staleEntries: allowlist.filter((entry) => !usedKeys.has(`${entry.category}|${entry.kind}|${entry.key}`)), + infos: comparison.infos, + }; +} + +export function formatReport(result: ParityResult, strict: boolean): string { + const lines: string[] = ["## Migration chain vs supabase/schema.sql", ""]; + if (result.infos.length > 0) { + for (const info of result.infos) lines.push(`- info: ${info}`); + lines.push(""); + } + if (result.allowed.length > 0) { + lines.push(`### Allowlisted divergence (${result.allowed.length})`, ""); + for (const { entry, finding } of result.allowed) { + lines.push(`- \`[${finding.category}] ${finding.kind} ${finding.key}\` — ${entry.reason}`); + } + lines.push(""); + } + if (result.staleEntries.length > 0) { + lines.push(`### Stale allowlist entries (${result.staleEntries.length}) — remove them`, ""); + for (const entry of result.staleEntries) { + const problems = parityEntryProblems(entry); + lines.push( + `- \`[${entry.category}] ${entry.kind} ${entry.key}\`${problems.length ? ` — invalid: ${problems.join("; ")}` : ""}`, + ); + } + lines.push(""); + } + if (result.findings.length === 0) { + lines.push("No divergence between the migration chain and supabase/schema.sql."); + return `${lines.join("\n")}\n`; + } + + lines.push(`### Divergence (${result.findings.length})`, ""); + for (const finding of result.findings) { + const direction = + finding.kind === "missing_live" + ? "schema.sql describes it, the migration chain never builds it" + : finding.kind === "unexpected_live" + ? "the migration chain builds it, schema.sql does not describe it" + : "both build it, with different definitions"; + lines.push(`- \`[${finding.category}] ${finding.kind} ${finding.key}\` — ${direction}`); + if (finding.detail) lines.push(` - \`${finding.detail.slice(0, 400)}\``); + } + lines.push(""); + lines.push( + strict + ? "These must be reconciled: fix the migration, or update supabase/schema.sql and regenerate the drift manifest (`npm run drift:manifest`)." + : "REPORT-ONLY: this gate does not fail the build yet. Copy the entries above into " + + "`supabase/chain-mirror-allowlist.json` with a reason each, or reconcile them, then add `--strict`.", + ); + return `${lines.join("\n")}\n`; +} + +function arg(flag: string): string | undefined { + const index = process.argv.indexOf(flag); + if (index < 0) return undefined; + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`); + return value; +} + +function readSnapshot(path: string, label: string): Record { + const raw = readFileSync(path, "utf8").trim(); + if (raw === "") throw new Error(`${label} snapshot at ${path} is empty — the capture step produced nothing`); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${label} snapshot at ${path} is not a schema_drift_snapshot() object`); + } + return parsed as Record; +} + +/** + * The drift manifest's `snapshot` is a schema.sql replay captured by + * `npm run drift:manifest`, so it IS the mirror side — no second replay needed. + * Read it defensively: a manifest regenerated from a different schema.sql would + * compare the wrong thing, and the CI job's own freshness check is what proves + * it matches (it runs immediately before this). + */ +export function mirrorSnapshotFromManifest(path: string): Record { + const manifest = JSON.parse(readFileSync(path, "utf8")) as { snapshot?: unknown; schema_sha256?: unknown }; + const snapshot = manifest.snapshot; + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + throw new Error(`${path} has no usable \`snapshot\` — regenerate it with \`npm run drift:manifest\``); + } + return snapshot as Record; +} + +export function selfTest(): void { + const failures: string[] = []; + const expect = (condition: boolean, label: string) => { + if (!condition) failures.push(label); + }; + + const base = { + captured_at: "2026-09-02T00:00:00Z", + functions: [{ signature: "public.f()", def_hash: "aaa", acl: [] }], + tables: [], + views: [], + indexes: [], + policies: [], + constraints: [], + triggers: [], + extensions: [], + storage_buckets: [], + }; + + // Identical builds diverge in nothing. + expect(compareChainAgainstMirror(base, { ...base }, []).findings.length === 0, "identical snapshots must be clean"); + + // The 2026-09-01 failure class: same function, different body. + const drifted = { ...base, functions: [{ signature: "public.f()", def_hash: "bbb", acl: [] }] }; + const bodyDiff = compareChainAgainstMirror(base, drifted, []); + expect( + bodyDiff.findings.length === 1 && bodyDiff.findings[0].kind === "mismatch", + "a diverging function body must be reported as a mismatch", + ); + + // schema.sql describes an object the chain never builds. + const missing = compareChainAgainstMirror(base, { ...base, functions: [] }, []); + expect( + missing.findings.length === 1 && missing.findings[0].kind === "missing_live", + "an object the chain never builds must be reported as missing", + ); + + // The chain builds something schema.sql does not describe. + const extra = compareChainAgainstMirror({ ...base, functions: [] }, base, []); + expect( + extra.findings.length === 1 && extra.findings[0].kind === "unexpected_live", + "an object only the chain builds must be reported as unexpected", + ); + + // A valid allowlist entry consumes its finding; an invalid one never can. + const good: ParityAllowlistEntry = { + category: "functions", + kind: "mismatch", + key: "public.f()", + reason: "known and reviewed divergence with a long enough explanation", + }; + const allowed = compareChainAgainstMirror(base, drifted, [good]); + expect(allowed.findings.length === 0 && allowed.allowed.length === 1, "a valid entry must consume its finding"); + + const bad: ParityAllowlistEntry = { ...good, reason: "too short" }; + const notAllowed = compareChainAgainstMirror(base, drifted, [bad]); + expect( + notAllowed.findings.length === 1 && notAllowed.staleEntries.length === 1, + "a malformed entry must never silence a finding", + ); + + // migration_history is stripped rather than allowlisted: the chain database + // has a supabase_migrations schema and the mirror never does. + const withHistory = { + ...base, + migration_history: [{ version: "20260101000000", name: "x", signal: "null" }], + migration_history_probe: "ok", + }; + expect( + compareChainAgainstMirror(base, withHistory, []).findings.length === 0, + "history rows must not surface as parity findings", + ); + + if (failures.length > 0) { + console.error("chain-mirror parity self-test FAILED:"); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); + } + console.log("chain-mirror parity self-test passed."); +} + +function main() { + if (process.argv.includes("--self-test")) { + selfTest(); + return; + } + + const chainPath = arg("--chain"); + const mirrorPath = arg("--mirror"); + const mirrorManifestPath = arg("--mirror-manifest"); + if (!chainPath || (!mirrorPath && !mirrorManifestPath)) { + throw new Error( + "--chain is required, plus one of --mirror (a schema_drift_snapshot() payload) or " + + "--mirror-manifest (supabase/drift-manifest.json)", + ); + } + const allowlistPath = arg("--allowlist") ?? "supabase/chain-mirror-allowlist.json"; + const strict = process.argv.includes("--strict"); + + const chain = readSnapshot(chainPath, "migration-chain"); + const mirror = mirrorPath + ? readSnapshot(mirrorPath, "schema.sql mirror") + : mirrorSnapshotFromManifest(mirrorManifestPath!); + const allowlistFile = JSON.parse(readFileSync(allowlistPath, "utf8")) as { entries?: ParityAllowlistEntry[] }; + const result = compareChainAgainstMirror(mirror, chain, allowlistFile.entries ?? []); + + const report = formatReport(result, strict); + console.log(report); + const summaryPath = arg("--summary") ?? process.env.GITHUB_STEP_SUMMARY; + if (summaryPath) writeFileSync(summaryPath, report, { flag: "a" }); + + if (result.findings.length > 0 && strict) process.exitCode = 1; +} + +const invokedDirectly = process.argv[1] && /check-chain-mirror-parity\.(ts|mts|js)$/.test(process.argv[1]); +if (invokedDirectly) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index 2160364a9a..1d68d6977f 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -301,7 +301,7 @@ const dbPatterns = [ "src/lib/supabase", "docs/database-drift-detection.md", "docs/supabase-migration-reconciliation.md", - /^scripts\/(check-drift|generate-drift-manifest|check-m13-migration|check-retrieval-owner-migration|check-supabase-project|audit-tables|reindex|reindex-health|cleanup-abandoned-reindex-generations)\.ts$/, + /^scripts\/(check-drift|check-chain-mirror-parity|generate-drift-manifest|check-m13-migration|check-retrieval-owner-migration|check-supabase-project|audit-tables|reindex|reindex-health|cleanup-abandoned-reindex-generations)\.ts$/, /^tests\/(supabase|drift|private-rag|private-access|retrieval-owner).*\.test\.ts$/, ]; diff --git a/supabase/chain-mirror-allowlist.json b/supabase/chain-mirror-allowlist.json new file mode 100644 index 0000000000..c921607de3 --- /dev/null +++ b/supabase/chain-mirror-allowlist.json @@ -0,0 +1,6 @@ +{ + "_comment": "Reviewed, documented divergence between what supabase/migrations/** BUILDS and what supabase/schema.sql DESCRIBES. Consumed by scripts/check-chain-mirror-parity.ts (npm run check:chain-mirror-parity) in the CI Migration replay job. This is NOT supabase/drift-allowlist.json and must never be merged with it: that file allowlists live-vs-mirror divergence, and an entry there blinds the live drift alarm. An entry here only says 'the chain and the mirror are knowingly different in this one place'.", + "_status": "EMPTY BY DESIGN. The gate ships report-only because the existing divergences cannot be enumerated offline - docs/database-drift-detection.md backlog item 10 already records ~13 chain-vs-mirror keys plus storage buckets that only schema.sql creates, but nobody has measured the current set. The first CI run prints exactly what belongs here; committing that list and adding --strict is the follow-up. Do not pre-seed guesses.", + "_entry_shape": "{ category: one of extensions|tables|views|functions|indexes|policies|constraints|triggers|storage_buckets, kind: missing_live|unexpected_live|mismatch, key: the snapshot key, reason: why this divergence is accepted rather than fixed (> 20 chars) }", + "entries": [] +} diff --git a/tests/chain-mirror-parity.test.ts b/tests/chain-mirror-parity.test.ts new file mode 100644 index 0000000000..dc129906e4 --- /dev/null +++ b/tests/chain-mirror-parity.test.ts @@ -0,0 +1,256 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + PARITY_CATEGORIES, + compareChainAgainstMirror, + formatReport, + parityEntryProblems, + withoutHistoryProbe, + type ParityAllowlistEntry, +} from "../scripts/check-chain-mirror-parity"; + +/** + * #QCNE6N. `supabase migration up --local` proves the migration chain APPLIES; + * the drift manifest's sha256 check proves the mirror is not stale. Neither + * compares what the chain BUILDS against what supabase/schema.sql DESCRIBES, so + * a diverging function body passed every pre-merge gate on 2026-09-01. + * + * These tests cover the half that can be proven offline: the comparison itself, + * the allowlist's fail-closed shape, and the CI wiring's internal consistency. + * The CI steps themselves cannot run here (no Docker daemon, no Supabase CLI, + * and the local PostgreSQL has no `vector` extension) and `db-reset-verify` is + * skipped on draft PRs, so their first real execution is on an undrafted PR. + */ + +const root = join(__dirname, ".."); +const read = (relative: string) => readFileSync(join(root, relative), "utf8"); + +const EMPTY_SNAPSHOT = { + captured_at: "2026-09-02T00:00:00Z", + extensions: [], + tables: [], + views: [], + functions: [], + indexes: [], + policies: [], + constraints: [], + triggers: [], + storage_buckets: [], +}; + +const withFunction = (defHash: string) => ({ + ...EMPTY_SNAPSHOT, + functions: [{ signature: "public.correct_clinical_query_terms(text,real)", def_hash: defHash, acl: [] }], +}); + +describe("chain vs schema.sql parity comparison", () => { + it("reports nothing when both builds agree", () => { + const result = compareChainAgainstMirror(withFunction("aaa"), withFunction("aaa"), []); + expect(result.findings).toEqual([]); + }); + + it("reports the 2026-09-01 failure class: same function, different body", () => { + const result = compareChainAgainstMirror(withFunction("aaa"), withFunction("bbb"), []); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + category: "functions", + kind: "mismatch", + key: "public.correct_clinical_query_terms(text,real)", + }); + }); + + it("reports an object schema.sql describes that the chain never builds", () => { + const result = compareChainAgainstMirror(withFunction("aaa"), EMPTY_SNAPSHOT, []); + expect(result.findings).toHaveLength(1); + expect(result.findings[0].kind).toBe("missing_live"); + }); + + it("reports an object only the chain builds", () => { + const result = compareChainAgainstMirror(EMPTY_SNAPSHOT, withFunction("aaa"), []); + expect(result.findings).toHaveLength(1); + expect(result.findings[0].kind).toBe("unexpected_live"); + }); + + it("compares policies and index shapes, not just functions", () => { + const mirror = { + ...EMPTY_SNAPSHOT, + policies: [ + { + schema: "public", + table: "documents", + name: "owner_read", + permissive: "PERMISSIVE", + roles: ["authenticated"], + cmd: "SELECT", + qual: "(owner_id = auth.uid())", + with_check: null, + }, + ], + indexes: [{ name: "documents_owner_id_idx", table: "documents", def_hash: "idx-a" }], + }; + const chain = { + ...mirror, + policies: [{ ...mirror.policies[0], qual: "(true)" }], + indexes: [{ ...mirror.indexes[0], def_hash: "idx-b" }], + }; + const kinds = compareChainAgainstMirror(mirror, chain, []).findings.map((f) => `${f.category}:${f.kind}`); + expect(kinds).toContain("policies:mismatch"); + expect(kinds).toContain("indexes:mismatch"); + }); + + it("strips the migration-history probe from both sides rather than allowlisting it", () => { + const chain = { + ...withFunction("aaa"), + migration_history: [{ version: "20260101000000", name: "x", signal: "null" }], + migration_history_probe: "ok", + }; + expect(compareChainAgainstMirror(withFunction("aaa"), chain, []).findings).toEqual([]); + expect(withoutHistoryProbe(chain)).not.toHaveProperty("migration_history"); + expect(withoutHistoryProbe(chain)).not.toHaveProperty("migration_history_probe"); + }); +}); + +describe("chain-mirror allowlist is fail-closed", () => { + const valid: ParityAllowlistEntry = { + category: "functions", + kind: "mismatch", + key: "public.correct_clinical_query_terms(text,real)", + reason: "reviewed divergence with an explanation long enough to be worth reading", + }; + + it("consumes a finding only for a structurally valid entry", () => { + const allowed = compareChainAgainstMirror(withFunction("aaa"), withFunction("bbb"), [valid]); + expect(allowed.findings).toEqual([]); + expect(allowed.allowed).toHaveLength(1); + }); + + it("never lets a malformed entry silence a divergence", () => { + for (const broken of [ + { ...valid, reason: "too short" }, + { ...valid, category: "not_a_category" }, + { ...valid, kind: "no_statements" as ParityAllowlistEntry["kind"] }, + { ...valid, key: "" }, + ]) { + expect(parityEntryProblems(broken).length, JSON.stringify(broken)).toBeGreaterThan(0); + const result = compareChainAgainstMirror(withFunction("aaa"), withFunction("bbb"), [broken]); + expect(result.findings, JSON.stringify(broken)).toHaveLength(1); + expect(result.staleEntries, JSON.stringify(broken)).toHaveLength(1); + } + }); + + it("reports an entry that matches nothing as stale", () => { + const result = compareChainAgainstMirror(withFunction("aaa"), withFunction("aaa"), [valid]); + expect(result.findings).toEqual([]); + expect(result.staleEntries).toHaveLength(1); + }); + + it("keeps the committed allowlist valid, and separate from the live drift allowlist", () => { + const file = JSON.parse(read("supabase/chain-mirror-allowlist.json")) as { + entries: ParityAllowlistEntry[]; + }; + expect(Array.isArray(file.entries)).toBe(true); + for (const entry of file.entries) { + expect(parityEntryProblems(entry), `invalid entry ${entry.category}/${entry.kind}/${entry.key}`).toEqual([]); + } + const keys = file.entries.map((entry) => `${entry.category}|${entry.kind}|${entry.key}`); + expect(new Set(keys).size, "duplicate allowlist entries").toBe(keys.length); + + // The live gate's allowlist must never absorb chain-vs-mirror divergence: an + // entry there blinds the weekly live-drift alarm, which is a different and + // much more consequential thing to go blind about. + const live = JSON.parse(read("supabase/drift-allowlist.json")) as { entries: { category: string }[] }; + expect(live.entries.every((entry) => entry.category === "migration_history")).toBe(true); + }); + + it("names every snapshot category the comparison covers", () => { + expect([...PARITY_CATEGORIES].sort()).toEqual( + [ + "constraints", + "extensions", + "functions", + "indexes", + "policies", + "storage_buckets", + "tables", + "triggers", + "views", + ].sort(), + ); + }); +}); + +describe("the report says which side is which", () => { + it("explains the direction of every divergence and how to clear it", () => { + const report = formatReport(compareChainAgainstMirror(withFunction("aaa"), withFunction("bbb"), []), false); + expect(report).toContain("both build it, with different definitions"); + expect(report).toContain("REPORT-ONLY"); + expect(report).toContain("chain-mirror-allowlist.json"); + expect(formatReport(compareChainAgainstMirror(withFunction("aaa"), EMPTY_SNAPSHOT, []), false)).toContain( + "schema.sql describes it, the migration chain never builds it", + ); + expect(formatReport(compareChainAgainstMirror(EMPTY_SNAPSHOT, withFunction("aaa"), []), false)).toContain( + "the migration chain builds it, schema.sql does not describe it", + ); + }); + + it("says so plainly when the two builds agree", () => { + expect(formatReport(compareChainAgainstMirror(withFunction("aaa"), withFunction("aaa"), []), true)).toContain( + "No divergence between the migration chain and supabase/schema.sql.", + ); + }); +}); + +describe("CI wiring for the parity gate", () => { + const workflow = read(".github/workflows/ci.yml"); + + it("runs the comparison inside the Migration replay job", () => { + expect(workflow).toContain("- name: Capture the migration chain's schema snapshot"); + expect(workflow).toContain("- name: Compare the migration chain against supabase/schema.sql"); + expect(workflow).toContain("npm run check:chain-mirror-parity --"); + }); + + it("captures the chain snapshot only after the chain has been replayed", () => { + const replay = workflow.indexOf("- name: Verify Migration Replay"); + const capture = workflow.indexOf("- name: Capture the migration chain's schema snapshot"); + const compare = workflow.indexOf("- name: Compare the migration chain against supabase/schema.sql"); + expect(replay).toBeGreaterThan(-1); + expect(capture).toBeGreaterThan(replay); + expect(compare).toBeGreaterThan(capture); + }); + + it("takes the mirror side from the manifest regenerated in the same job", () => { + const regenerate = workflow.indexOf("- name: Regenerate and verify drift manifest freshness"); + const compare = workflow.indexOf("- name: Compare the migration chain against supabase/schema.sql"); + expect(compare).toBeGreaterThan(regenerate); + expect(workflow).toContain("--mirror-manifest supabase/drift-manifest.json"); + }); + + it("ties report-only mode to the failure tolerance, so flipping one forces the other", () => { + const parityBlock = workflow.slice( + workflow.indexOf("- name: Capture the migration chain's schema snapshot"), + workflow.indexOf("- name: Upload regenerated drift manifest"), + ); + const strict = parityBlock.includes("--strict"); + const tolerated = parityBlock.split("continue-on-error: true").length - 1; + + if (strict) { + // Blocking mode: the steps must be able to fail the job, or "strict" is a lie. + expect(tolerated, "a --strict parity gate must not carry continue-on-error").toBe(0); + } else { + // Report-only mode: shipping an unproven gate must not break Migration + // replay for every other PR, so both steps stay tolerant. + expect(tolerated, "report-only parity steps must not be able to fail the job").toBe(2); + } + }); + + it("says out loud when the comparison produced no evidence", () => { + expect(workflow).toContain("- name: Report a missing chain/mirror parity capture"); + expect(workflow).toContain("chain/mirror parity did not run"); + }); + + it("routes a change to the parity script into the job that runs it", () => { + expect(read("scripts/ci-change-scope.mjs")).toContain("check-chain-mirror-parity"); + }); +}); From 14c031635a7f3ee8d5b4ad9f81e3cd151fa14fed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 05:32:07 +0000 Subject: [PATCH 2/7] fix(drift): act on the parity gate's security review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real defects found reviewing the chain/mirror parity gate, all in the half that could not be executed locally. 1. The report repeated advice that is false here and points the wrong way. compareDriftSnapshots emits an info line whenever a snapshot carries no migration-history probe: "migration 20260818090000 is not deployed; the schema_drift_snapshot() function mismatch is that pending deploy, not a body regression". True for the live gate. Here the probe IS applied in the chain database and this script stripped it deliberately — and the sentence coaches the reader to dismiss exactly the class of finding this gate exists to surface. Suppressed, with a test asserting neither the info list nor the rendered report can carry it. 2. A crashing compare step was indistinguishable from a clean one. Both parity steps carry continue-on-error, and the warning step only covered a failed capture, so a permanently-throwing comparison was a grey mark nobody reads. The warning now covers both outcomes and names which one failed. 3. The suite imposed a new policy on a file this gate does not own. It asserted every entry in supabase/drift-allowlist.json is migration_history, but check:drift legitimately supports object-category entries there — a future live entry would have failed an unrelated chain-mirror test with a confusing message. Replaced with an assertion of what actually matters: the two gates never read each other's allowlist. Also from the review: - Report-only mode had no way to end. The strict/tolerance tie is satisfied forever by a gate that never becomes strict, and the only forcing function was a code comment. Report-only now expires 2026-12-01: past that date the test goes red unless --strict is present, with a message saying exactly what to do. Moving the deadline needs a reason in the PR body. - A found divergence emitted nothing GitHub renders. While report-only, the annotation IS the output, so the script now emits ::warning:: with the count. - The capture step built its container name through `docker ps | grep | head` under `set -o pipefail`, where a SIGPIPE from head fails the step and grep exiting 1 on no match aborts before the explicit `test -n` can explain why. Uses `docker ps --filter` instead, and a test pins that. - Renamed withoutHistoryProbe to comparableSnapshot, since it also strips captured_at and the old name would mislead the next reader. Verified on a real invocation rather than fixtures: a snapshot pair built from the committed manifest reports "No divergence" with no suppressed advice; the same pair with one function def_hash altered reports the mismatch, emits the ::warning::, exits 0 report-only and exits 1 under --strict. 22 tests in the parity suite; full suite 948 files / 12077 tests passed; lint, typecheck, check:github-actions, check:ci-scope green. Still not verified, unchanged from the first commit: the CI steps have never run. No Docker daemon, no Supabase CLI, no vector extension here, and db-reset-verify is skipped on draft PRs. Refs #QCNE6N Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sjekpEw82gMp57C8xzSxZ --- .github/workflows/ci.yml | 14 +++-- scripts/check-chain-mirror-parity.ts | 43 ++++++++++++--- tests/chain-mirror-parity.test.ts | 81 ++++++++++++++++++++++++---- 3 files changed, 116 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 237cab4d4c..2341b31fb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1121,7 +1121,10 @@ jobs: continue-on-error: true run: | set -euo pipefail - db_container="$(docker ps --format '{{.Names}}' | grep '^supabase_db_' | head -n 1)" + # --filter rather than `| grep | head`: under `set -o pipefail` a SIGPIPE + # from head is a step failure, and grep exiting 1 on no match aborts + # before the explicit `test -n` can say what went wrong. + db_container="$(docker ps --format '{{.Names}}' --filter 'name=^supabase_db_' | head -n 1)" test -n "${db_container}" docker exec -i "${db_container}" \ psql -U postgres -d postgres -tA -v ON_ERROR_STOP=1 \ @@ -1137,10 +1140,13 @@ jobs: --chain /tmp/chain-snapshot.json \ --mirror-manifest supabase/drift-manifest.json - - name: Report a missing chain/mirror parity capture - if: steps.chain-snapshot.outcome != 'success' + - name: Report a chain/mirror parity step that produced no evidence + # Both steps above carry continue-on-error, so without this a crashing + # compare step is a grey mark nobody reads and is indistinguishable from + # a clean run. Cover the compare outcome too, not just the capture. + if: steps.chain-snapshot.outcome != 'success' || steps.chain-mirror-parity.outcome != 'success' run: | - echo "::warning::chain/mirror parity did not run - the chain snapshot capture failed. It is report-only today so it does not block the merge, but it means the comparison produced no evidence." + echo "::warning::chain/mirror parity produced no evidence (capture=${{ steps.chain-snapshot.outcome }}, compare=${{ steps.chain-mirror-parity.outcome }}). It is report-only today so it does not block the merge, but the comparison did not run." - name: Upload regenerated drift manifest if: always() diff --git a/scripts/check-chain-mirror-parity.ts b/scripts/check-chain-mirror-parity.ts index fbd8408ae7..8ffefdc345 100644 --- a/scripts/check-chain-mirror-parity.ts +++ b/scripts/check-chain-mirror-parity.ts @@ -103,11 +103,16 @@ export function parityEntryProblems(entry: ParityAllowlistEntry): string[] { } /** - * The migration-history probe is live-only by design. Removing it from both - * sides here keeps `compareDriftSnapshots` from reporting the chain database's - * (entirely expected) `supabase_migrations` rows as parity findings. + * Reduce a snapshot to the parts worth comparing between two builds. + * + * `migration_history` / `migration_history_probe` are live-only by design: + * removing them from both sides keeps `compareDriftSnapshots` from reporting the + * chain database's (entirely expected) `supabase_migrations` rows as parity + * findings. `captured_at` goes too — it is a timestamp, it is in no compared + * category, and the manifest side has already dropped it, so leaving it in would + * only make the two shapes differ for no reason. */ -export function withoutHistoryProbe(snapshot: Record): Record { +export function comparableSnapshot(snapshot: Record): Record { const copy = { ...snapshot }; delete copy.migration_history; delete copy.migration_history_probe; @@ -115,6 +120,19 @@ export function withoutHistoryProbe(snapshot: Record): Record parityEntryProblems(entry).length === 0); const comparison = compareDriftSnapshots( - withoutHistoryProbe(mirror), - withoutHistoryProbe(chain), + comparableSnapshot(mirror), + comparableSnapshot(chain), valid as unknown as AllowlistEntry[], ); @@ -150,7 +168,7 @@ export function compareChainAgainstMirror( finding, })), staleEntries: allowlist.filter((entry) => !usedKeys.has(`${entry.category}|${entry.kind}|${entry.key}`)), - infos: comparison.infos, + infos: comparison.infos.filter((info) => !SUPPRESSED_INFO.test(info)), }; } @@ -348,6 +366,17 @@ function main() { const summaryPath = arg("--summary") ?? process.env.GITHUB_STEP_SUMMARY; if (summaryPath) writeFileSync(summaryPath, report, { flag: "a" }); + // A job summary nobody is required to open is not a signal. While the gate is + // report-only the annotation IS the output — without it, "the gate found + // thirteen divergences" and "the gate has been silently crashing for a month" + // look identical from the checks list. + if (result.findings.length > 0) { + console.log( + `::warning::chain/mirror parity: ${result.findings.length} divergence(s) between the migration chain and ` + + `supabase/schema.sql — see this job's summary${strict ? "" : " (report-only, not blocking the merge)"}.`, + ); + } + if (result.findings.length > 0 && strict) process.exitCode = 1; } diff --git a/tests/chain-mirror-parity.test.ts b/tests/chain-mirror-parity.test.ts index dc129906e4..9cf3479b68 100644 --- a/tests/chain-mirror-parity.test.ts +++ b/tests/chain-mirror-parity.test.ts @@ -7,7 +7,7 @@ import { compareChainAgainstMirror, formatReport, parityEntryProblems, - withoutHistoryProbe, + comparableSnapshot, type ParityAllowlistEntry, } from "../scripts/check-chain-mirror-parity"; @@ -107,8 +107,8 @@ describe("chain vs schema.sql parity comparison", () => { migration_history_probe: "ok", }; expect(compareChainAgainstMirror(withFunction("aaa"), chain, []).findings).toEqual([]); - expect(withoutHistoryProbe(chain)).not.toHaveProperty("migration_history"); - expect(withoutHistoryProbe(chain)).not.toHaveProperty("migration_history_probe"); + expect(comparableSnapshot(chain)).not.toHaveProperty("migration_history"); + expect(comparableSnapshot(chain)).not.toHaveProperty("migration_history_probe"); }); }); @@ -157,11 +157,14 @@ describe("chain-mirror allowlist is fail-closed", () => { const keys = file.entries.map((entry) => `${entry.category}|${entry.kind}|${entry.key}`); expect(new Set(keys).size, "duplicate allowlist entries").toBe(keys.length); - // The live gate's allowlist must never absorb chain-vs-mirror divergence: an - // entry there blinds the weekly live-drift alarm, which is a different and - // much more consequential thing to go blind about. - const live = JSON.parse(read("supabase/drift-allowlist.json")) as { entries: { category: string }[] }; - expect(live.entries.every((entry) => entry.category === "migration_history")).toBe(true); + // The two allowlists must stay separate: an entry in the live one blinds the + // weekly live-drift alarm, a different and far more consequential thing to go + // blind about. Assert SEPARATION, not a policy on the live file's contents — + // check:drift legitimately supports object-category entries there, and + // constraining that from this suite would fail a future live entry with a + // confusing message from an unrelated gate. + expect(read("scripts/check-drift.ts")).not.toContain("chain-mirror-allowlist"); + expect(read("scripts/check-chain-mirror-parity.ts")).not.toContain("drift-allowlist.json"); }); it("names every snapshot category the comparison covers", () => { @@ -195,6 +198,22 @@ describe("the report says which side is which", () => { ); }); + it("drops the live gate's history-probe advice, which is false and harmful here", () => { + // compareDriftSnapshots emits "migration 20260818090000 is not deployed; the + // schema_drift_snapshot() function mismatch is that pending deploy, not a + // body regression" whenever a snapshot has no history probe. True for the + // live gate. Here the probe IS applied and this script stripped it — and the + // sentence coaches the reader to dismiss exactly the class of finding this + // gate exists to surface. + const chain = { ...withFunction("aaa"), migration_history: [], migration_history_probe: "ok" }; + const result = compareChainAgainstMirror(withFunction("aaa"), chain, []); + for (const info of result.infos) { + expect(info).not.toMatch(/migration-history probe not present/i); + expect(info).not.toMatch(/not a body regression/i); + } + expect(formatReport(result, false)).not.toMatch(/not a body regression/i); + }); + it("says so plainly when the two builds agree", () => { expect(formatReport(compareChainAgainstMirror(withFunction("aaa"), withFunction("aaa"), []), true)).toContain( "No divergence between the migration chain and supabase/schema.sql.", @@ -245,9 +264,49 @@ describe("CI wiring for the parity gate", () => { } }); - it("says out loud when the comparison produced no evidence", () => { - expect(workflow).toContain("- name: Report a missing chain/mirror parity capture"); - expect(workflow).toContain("chain/mirror parity did not run"); + it("says out loud when either parity step produced no evidence", () => { + // Both parity steps carry continue-on-error, so a crashing compare step is a + // grey mark nobody reads. Without covering its outcome too, "found thirteen + // divergences" and "has been crashing for a month" look identical. + expect(workflow).toContain("- name: Report a chain/mirror parity step that produced no evidence"); + expect(workflow).toContain( + "steps.chain-snapshot.outcome != 'success' || steps.chain-mirror-parity.outcome != 'success'", + ); + expect(workflow).toContain("chain/mirror parity produced no evidence"); + }); + + it("does not build the container name through a pipeline that set -o pipefail can kill", () => { + // `docker ps | grep | head` under `set -o pipefail` fails the step on a + // SIGPIPE from head, and grep exiting 1 on no match aborts before the + // explicit `test -n` can explain what happened. + const parityBlock = workflow.slice( + workflow.indexOf("- name: Capture the migration chain's schema snapshot"), + workflow.indexOf("- name: Upload regenerated drift manifest"), + ); + expect(parityBlock).toContain("--filter 'name=^supabase_db_'"); + expect(parityBlock).not.toContain("| grep '^supabase_db_'"); + }); + + it("report-only mode has an expiry, so the follow-up cannot be forgotten", () => { + // The strict/tolerance tie above keeps the two consistent but is satisfied + // forever by a gate that never becomes strict. The only forcing function for + // ending report-only is otherwise a code comment. Once the first real CI run + // has printed the divergence set there is nothing left to wait for, so this + // goes red if the phase is still open past the deadline. + const REPORT_ONLY_DEADLINE = new Date("2026-12-01T00:00:00Z"); + const parityBlock = workflow.slice( + workflow.indexOf("- name: Capture the migration chain's schema snapshot"), + workflow.indexOf("- name: Upload regenerated drift manifest"), + ); + const stillReportOnly = !parityBlock.includes("--strict"); + if (!stillReportOnly) return; + + expect( + Date.now(), + "chain/mirror parity is still report-only past its deadline. The first CI run has long since printed the " + + "divergence set: commit it to supabase/chain-mirror-allowlist.json with a reason each, add --strict, and " + + "remove the continue-on-error lines in the same change. Move the deadline only with a reason in the PR body.", + ).toBeLessThan(REPORT_ONLY_DEADLINE.getTime()); }); it("routes a change to the parity script into the job that runs it", () => { From f3d17001295ca0087e60f5d04059762847902417 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 06:01:59 +0000 Subject: [PATCH 3/7] docs(drift): document the chain/mirror parity gate in the runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review noted the gate landed with no explanation anywhere an operator reads about drift, and that reconciliation backlog item 10 — the 13-key chain-vs- mirror measurement from 2026-07-07 — did not mention that anything now re-measures it. Adds a section beside the guard-migration contract covering what the gate compares and why, the 2026-09-01 incident that motivated it, the known image asymmetry and the two approaches that were tried and rejected before settling on it, the report-only phase and its 2026-12-01 expiry, exactly what ending that phase takes, and why the parity allowlist must never merge with the live drift allowlist. Backlog item 10 now says its count is a hand measurement that has never been re-taken, and points at the gate that re-takes it. Also carries forward a trap the review spotted: the matcher in check-gate-manifest.mjs that pairs a local gate with its CI step is anchored to a single-line `run:`, so this gate's multi-line step would not register as CI coverage if it were ever added to verify:cheap. A contract test pins the section, the expiry date, the separation rule, and the backlog cross-reference, so the documentation cannot quietly fall out of step with the gate. Verified: 23 tests in the parity suite; full suite 948 files / 12078 tests passed; docs:check-scripts, docs:check-links and docs:check-inventory green. Refs #QCNE6N Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sjekpEw82gMp57C8xzSxZ --- docs/database-drift-detection.md | 69 ++++++++++++++++++++++++++++++- tests/chain-mirror-parity.test.ts | 13 ++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/docs/database-drift-detection.md b/docs/database-drift-detection.md index 5a85212de0..37bc7dec44 100644 --- a/docs/database-drift-detection.md +++ b/docs/database-drift-detection.md @@ -222,6 +222,70 @@ history is squashed and the row disappears) the entry shows as stale on the next run — delete it. Never widen a class or drop `objects` to make an entry pass; the finding is the point. +## Chain-vs-mirror parity (`npm run check:chain-mirror-parity`) + +`check:drift` compares **live** against `supabase/schema.sql`. CI's `db-reset-verify` +proves the migration chain **applies** (`supabase migration up --local`) and that the +drift manifest is **not stale** (committed vs generated `schema_sha256`). Nothing +compared the _result_ of the chain against the mirror, so a migration whose function +body or policy predicate diverged from `schema.sql` passed every pre-merge gate. + +That is `#QCNE6N`, and it is not theoretical. On 2026-09-01 migration +`20260831100000` (PR #2477) redefined `public.correct_clinical_query_terms(text,real)` +with a duplicated predicate and `schema.sql` was never updated to match. Every +pre-merge gate stayed green; the post-merge `live-drift` run went red on `main` +(`def_hash` manifest `e2356565` vs live `2ebaf978`). Behaviour impact was nil — the +duplicate predicate is a boolean no-op — but it cost a red daily alarm and a +remediation PR, and it is the second occurrence of this class after `#316`. + +**How it runs.** In `db-reset-verify`, after the replay and after `drift:manifest` +regenerates the manifest from this PR's `schema.sql`: + +- the **chain** side is `public.schema_drift_snapshot()` read out of the Supabase + emulator database the migrations just built; +- the **mirror** side is that regenerated manifest's `snapshot`, which is a + `schema.sql` replay — so no second replay is built; +- `scripts/check-chain-mirror-parity.ts` compares them with `compareDriftSnapshots`, + the same comparator the live gate uses. Two comparators would eventually disagree + about what "different" means, and then one of them would be wrong. + +`migration_history` and `migration_history_probe` are stripped from both sides **by +construction**, never allowlisted: a `schema.sql` replay has no `supabase_migrations` +schema, so the category can only produce noise here. It stays the live gate's business. + +**Known asymmetry.** The two sides come from different images — the emulator versus +the pinned bare `supabase/postgres` — so some reported difference is platform +provenance rather than real divergence. Building both sides identically was tried and +does not work: a mirror database created inside the emulator has no `auth` schema for +`schema.sql`'s `references auth.users(id)` columns, and reproducing the chain inside +the bare image means driving the whole chain by hand rather than through +`supabase migration up`. + +**Report-only, with an expiry.** The gate lands printing divergences rather than +blocking, because the existing set (backlog item 10 above, plus schema.sql-only +storage buckets) has never been measured and blocking on an unmeasured set just +teaches people to ignore a red check. It emits a `::warning::` on any divergence and a +second one if either step produced no evidence, so a silently-crashing gate cannot be +mistaken for a clean one. `tests/chain-mirror-parity.test.ts` ties the mode to the +failure tolerance — `--strict` and `continue-on-error` cannot coexist — and expires +report-only mode on **2026-12-01**, after which the suite goes red until the phase +ends. + +**Ending report-only** is one small PR: take the divergence list from a real run's job +summary, commit it to `supabase/chain-mirror-allowlist.json` with a reason each, add +`--strict`, and delete the `continue-on-error` lines in the same change. + +**`supabase/chain-mirror-allowlist.json` is not `supabase/drift-allowlist.json`, and +they must never merge.** An entry in the live allowlist blinds the weekly live-drift +alarm. An entry here only says "the chain and the mirror are knowingly different in +this one place", and the live gate still catches the consequence post-merge. A test +asserts neither script reads the other's file. + +> Note for whoever adds this to `verify:cheap` later: the matcher in +> `check-gate-manifest.mjs` that pairs a local gate with its CI step is anchored to a +> single-line `run:` invocation, and this gate's CI step uses a multi-line `run: |`. +> It would not register as CI coverage without changing that matcher first. + ## Runtime index-monitoring ratchet `search_schema_health()` monitors a curated `required_indexes` list (22 names @@ -388,4 +452,7 @@ live project need explicit operator approval. live): 13 keys where the chain diverges from schema.sql — buckets are only created by schema.sql, `documents`/`ingestion_jobs` updated*at trigger variants, post-legacy-drop embedding-fields index set, - `document_chunks_content_trgm_idx` shape, `rag_visual_eval*\*` shapes. + `document_chunks_content_trgm_idx` shape, `rag_visual_eval*\*` shapes. That + count is a 2026-07-07 hand measurement and has never been re-measured; the + chain/mirror parity gate above is what re-measures it, on every + database-touching PR. diff --git a/tests/chain-mirror-parity.test.ts b/tests/chain-mirror-parity.test.ts index 9cf3479b68..473510f7ef 100644 --- a/tests/chain-mirror-parity.test.ts +++ b/tests/chain-mirror-parity.test.ts @@ -312,4 +312,17 @@ describe("CI wiring for the parity gate", () => { it("routes a change to the parity script into the job that runs it", () => { expect(read("scripts/ci-change-scope.mjs")).toContain("check-chain-mirror-parity"); }); + + it("is documented where operators read about drift, with its expiry", () => { + // A gate whose only explanation is a comment in ci.yml is a gate the next + // operator will not know exists, let alone know is report-only. + const runbook = read("docs/database-drift-detection.md"); + expect(runbook).toContain("## Chain-vs-mirror parity (`npm run check:chain-mirror-parity`)"); + expect(runbook).toContain("2026-12-01"); + expect(runbook).toContain("must never merge"); + // Backlog item 10 is the measurement this gate re-takes; it should say so. + // Prettier reflows prose, so match on collapsed whitespace rather than on + // wherever the line happens to wrap today. + expect(runbook.replace(/\s+/g, " ")).toContain("chain/mirror parity gate above is what re-measures it"); + }); }); From b841240904e7a47bf6c9d84988fb5ccf4fbb2532 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 07:46:11 +0000 Subject: [PATCH 4/7] fix(drift): treat a chain-only extension as a parity finding (#QCNE6N) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parity gate reuses compareDriftSnapshots so the two gates cannot disagree about what "different" means. That reuse carried over one rule that inverts here: the live gate demotes an extension present on the "live" side but absent from schema.sql to an info line, because Supabase provisions pg_net and pgsodium that no migration creates. On this comparison the "live" side is the migration chain — our own code — so the same demotion excuses a real divergence, and one already exists. Migration 20260901033250 runs `create extension if not exists pg_cron`; supabase/schema.sql declares six extensions and pg_cron is not among them. Probed against the committed drift manifest, the inherited behaviour returned findings: [], so even a future --strict run would have passed it. Extensions the chain creates and the mirror never declares are now re-promoted to unexpected_live findings, before the allowlist is applied, so only a reviewed entry naming the extension can excuse one. The genuine emulator-image asymmetry goes in supabase/chain-mirror-allowlist.json one entry at a time rather than under a blanket rule that cannot tell the two cases apart. The demoting info line is suppressed so it cannot print beside the finding it was hiding. Four self-test cases and four regression tests, including one pinning that pg_cron really is absent from the committed mirror and really is created by that migration — so the case stays live rather than becoming a fixture that agrees with itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sjekpEw82gMp57C8xzSxZ --- docs/database-drift-detection.md | 10 +++ scripts/check-chain-mirror-parity.ts | 128 +++++++++++++++++++++++++-- tests/chain-mirror-parity.test.ts | 52 +++++++++++ 3 files changed, 181 insertions(+), 9 deletions(-) diff --git a/docs/database-drift-detection.md b/docs/database-drift-detection.md index 37bc7dec44..303c60adf1 100644 --- a/docs/database-drift-detection.md +++ b/docs/database-drift-detection.md @@ -224,6 +224,16 @@ pass; the finding is the point. ## Chain-vs-mirror parity (`npm run check:chain-mirror-parity`) +**Extensions are compared, not excused.** `check:drift` treats an extension present on the live +side but not in `schema.sql` as platform provenance and prints it as an info line — right for the +live gate, where Supabase provisions `pg_net` and `pgsodium` that no migration creates. On this +comparison the "live" side is the migration chain, which is our own code, so the parity script +re-promotes those to `unexpected_live` findings. There is already a real one: +`20260901033250_enable_staging_privacy_retention_schedules.sql` runs +`create extension if not exists pg_cron` and `supabase/schema.sql` never declares it, so under the +inherited rule the pair reported nothing at all. An extension the emulator image genuinely +provisions belongs in `supabase/chain-mirror-allowlist.json` as a reviewed entry, one at a time. + `check:drift` compares **live** against `supabase/schema.sql`. CI's `db-reset-verify` proves the migration chain **applies** (`supabase migration up --local`) and that the drift manifest is **not stale** (committed vs generated `schema_sha256`). Nothing diff --git a/scripts/check-chain-mirror-parity.ts b/scripts/check-chain-mirror-parity.ts index 8ffefdc345..d4fbe6c1a0 100644 --- a/scripts/check-chain-mirror-parity.ts +++ b/scripts/check-chain-mirror-parity.ts @@ -32,8 +32,12 @@ import { compareDriftSnapshots, type AllowlistEntry, type Finding } from "./chec * The chain side is the Supabase local emulator's database; the mirror side is * `drift:manifest`'s replay into the pinned bare `supabase/postgres` image plus * scripts/sql/drift-replay-scaffold.sql. Two different images, so some reported - * difference will be platform provenance (extension sets, the storage schema, - * role ACL arrays) rather than a real chain-vs-mirror divergence. Building both + * difference will be platform provenance (the storage schema, role ACL arrays) + * rather than a real chain-vs-mirror divergence. Extensions are deliberately NOT + * on that list: the live gate waves an extra "live" extension through as + * platform-provisioned, but here the "live" side is the migration chain, and + * doing so hid a real one (see SUPPRESSED_EXTENSION_INFO). An image-provisioned + * extension goes in the allowlist as a reviewed entry instead. Building both * sides identically would be better, but a mirror database created inside the * emulator does not inherit the `auth` schema that supabase/schema.sql needs for * its `references auth.users(id)` columns, and reproducing the emulator's chain @@ -133,6 +137,44 @@ export function comparableSnapshot(snapshot: Record): Record): Set { + const rows = Array.isArray(snapshot.extensions) ? snapshot.extensions : []; + return new Set( + rows + .map((row) => (row && typeof row === "object" ? String((row as Record).name ?? "") : "")) + .filter((name) => name !== ""), + ); +} + +/** Extensions the migration chain creates that supabase/schema.sql never declares. */ +export function chainOnlyExtensionFindings(mirror: Record, chain: Record): Finding[] { + const described = extensionsByName(mirror); + return [...extensionsByName(chain)] + .filter((name) => !described.has(name)) + .sort() + .map((key) => ({ category: "extensions", kind: "unexpected_live", key }) as Finding); +} + export type ParityResult = { findings: Finding[]; allowed: { entry: ParityAllowlistEntry; finding: Finding }[]; @@ -158,17 +200,32 @@ export function compareChainAgainstMirror( valid as unknown as AllowlistEntry[], ); - const usedKeys = new Set( - comparison.allowed.map(({ finding }) => `${finding.category}|${finding.kind}|${finding.key}`), - ); - return { - findings: comparison.findings, - allowed: comparison.allowed.map(({ entry, finding }) => ({ + // Re-promoted before the allowlist is applied, so a chain-only extension can be + // excused only by a reviewed entry naming it — never by the blanket platform rule. + const promotedFindings: Finding[] = []; + const promotedAllowed: { entry: ParityAllowlistEntry; finding: Finding }[] = []; + for (const finding of chainOnlyExtensionFindings(mirror, chain)) { + const entry = valid.find( + (candidate) => + candidate.category === finding.category && candidate.kind === finding.kind && candidate.key === finding.key, + ); + if (entry) promotedAllowed.push({ entry, finding }); + else promotedFindings.push(finding); + } + + const allowed = [ + ...comparison.allowed.map(({ entry, finding }) => ({ entry: entry as unknown as ParityAllowlistEntry, finding, })), + ...promotedAllowed, + ]; + const usedKeys = new Set(allowed.map(({ finding }) => `${finding.category}|${finding.kind}|${finding.key}`)); + return { + findings: [...comparison.findings, ...promotedFindings], + allowed, staleEntries: allowlist.filter((entry) => !usedKeys.has(`${entry.category}|${entry.kind}|${entry.key}`)), - infos: comparison.infos.filter((info) => !SUPPRESSED_INFO.test(info)), + infos: comparison.infos.filter((info) => !SUPPRESSED_INFO.test(info) && !SUPPRESSED_EXTENSION_INFO.test(info)), }; } @@ -316,6 +373,59 @@ export function selfTest(): void { "a malformed entry must never silence a finding", ); + // The real pg_cron case: the chain creates an extension schema.sql never + // declares. The reused live comparator demotes this to an info line, so before + // the fix this pair reported nothing at all and even --strict would have passed. + const chainOnlyExtension = compareChainAgainstMirror( + base, + { ...base, extensions: [{ name: "pg_cron", schema: "pg_catalog" }] }, + [], + ); + expect( + chainOnlyExtension.findings.length === 1 && + chainOnlyExtension.findings[0].category === "extensions" && + chainOnlyExtension.findings[0].kind === "unexpected_live" && + chainOnlyExtension.findings[0].key === "pg_cron", + "an extension only the migration chain creates must be reported, not demoted to an info line", + ); + expect( + chainOnlyExtension.infos.every((info) => !/extra live extension/i.test(info)), + "the demoting info line must not survive alongside the finding it was hiding", + ); + + // An extension genuinely provisioned by the emulator image goes through the + // parity allowlist, one reviewed entry at a time. + const allowedExtension = compareChainAgainstMirror( + base, + { ...base, extensions: [{ name: "pg_net", schema: "extensions" }] }, + [ + { + category: "extensions", + kind: "unexpected_live", + key: "pg_net", + reason: "provisioned by the Supabase emulator image, not by any migration in the chain", + }, + ], + ); + expect( + allowedExtension.findings.length === 0 && + allowedExtension.allowed.length === 1 && + allowedExtension.staleEntries.length === 0, + "a reviewed extension entry must consume its finding without reading as stale", + ); + + // An extension schema.sql declares and the chain never creates is the other + // direction, and was already a finding. + const missingExtension = compareChainAgainstMirror( + { ...base, extensions: [{ name: "vector", schema: "extensions" }] }, + base, + [], + ); + expect( + missingExtension.findings.length === 1 && missingExtension.findings[0].kind === "missing_live", + "an extension schema.sql declares but the chain never creates must still be reported", + ); + // migration_history is stripped rather than allowlisted: the chain database // has a supabase_migrations schema and the mirror never does. const withHistory = { diff --git a/tests/chain-mirror-parity.test.ts b/tests/chain-mirror-parity.test.ts index 473510f7ef..5a48e6e2dd 100644 --- a/tests/chain-mirror-parity.test.ts +++ b/tests/chain-mirror-parity.test.ts @@ -112,6 +112,58 @@ describe("chain vs schema.sql parity comparison", () => { }); }); +describe("an extension only the migration chain creates is a divergence, not platform noise", () => { + const withExtension = (...names: string[]) => ({ + ...EMPTY_SNAPSHOT, + extensions: names.map((name) => ({ name, schema: "extensions" })), + }); + + it("reports the real pg_cron case the reused comparator demoted to an info line", () => { + // 20260901033250_enable_staging_privacy_retention_schedules.sql runs + // `create extension if not exists pg_cron`; supabase/schema.sql never declares it. + // The live gate treats an extra live extension as platform provenance — correct there, + // where the platform provisions pg_net and pgsodium, and wrong here, where the "live" + // side is our own migration chain. Before the fix this pair reported nothing at all. + const result = compareChainAgainstMirror(withExtension(), withExtension("pg_cron"), []); + expect(result.findings).toEqual([{ category: "extensions", kind: "unexpected_live", key: "pg_cron" }]); + expect(result.infos.filter((info) => /extra live extension/i.test(info))).toEqual([]); + }); + + it("routes a genuinely image-provisioned extension through the parity allowlist instead", () => { + const entry: ParityAllowlistEntry = { + category: "extensions", + kind: "unexpected_live", + key: "pg_net", + reason: "provisioned by the Supabase emulator image, not created by any migration in the chain", + }; + const result = compareChainAgainstMirror(withExtension(), withExtension("pg_net"), [entry]); + expect(result.findings).toEqual([]); + expect(result.allowed).toHaveLength(1); + // The entry must not also read as stale — that would print "remove them" beside the + // divergence it is deliberately holding. + expect(result.staleEntries).toEqual([]); + }); + + it("still reports an extension schema.sql declares that the chain never creates", () => { + const result = compareChainAgainstMirror(withExtension("vector"), withExtension(), []); + expect(result.findings).toEqual([ + { category: "extensions", kind: "missing_live", key: "vector", detail: expect.anything() }, + ]); + }); + + it("keeps pg_cron absent from the committed mirror, so this is a live divergence and not a fixture", () => { + const manifest = JSON.parse(readFileSync(join(process.cwd(), "supabase", "drift-manifest.json"), "utf8")); + const declared = (manifest.snapshot?.extensions ?? []).map((row: { name: string }) => row.name); + expect(declared.length).toBeGreaterThan(0); + expect(declared).not.toContain("pg_cron"); + const migration = readFileSync( + join(process.cwd(), "supabase", "migrations", "20260901033250_enable_staging_privacy_retention_schedules.sql"), + "utf8", + ); + expect(migration).toMatch(/create\s+extension\s+if\s+not\s+exists\s+pg_cron/i); + }); +}); + describe("chain-mirror allowlist is fail-closed", () => { const valid: ParityAllowlistEntry = { category: "functions", From 921c9d2034d0957cd2a4ee24960789c612fc3f1d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 07:51:08 +0000 Subject: [PATCH 5/7] chore(drift): commit the parity baseline the first real CI run measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate's first real execution (Migration replay, run 33599464239) reported zero structural divergence across tables, views, functions, indexes, policies, constraints, triggers and storage buckets. docs/database-drift-detection.md backlog item 10 estimated ~13 chain-vs-mirror keys; that estimate had never been re-measured and it was stale. Only two extensions differed. pg_net is allowlisted with its evidence: no file under supabase/migrations/** creates it and supabase/schema.sql never declares it, so neither side of the comparison asked for it — the emulator-image asymmetry this file exists for. pg_cron is deliberately NOT allowlisted. 20260901033250 creates it and supabase/schema.sql does not declare it, so it is a real mirror gap and the finding the gate exists to surface. Closing it means declaring the extension in schema.sql and regenerating the drift manifest, which needs Docker, and belongs with the migration batch rather than here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sjekpEw82gMp57C8xzSxZ --- supabase/chain-mirror-allowlist.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/supabase/chain-mirror-allowlist.json b/supabase/chain-mirror-allowlist.json index c921607de3..9b75c8705e 100644 --- a/supabase/chain-mirror-allowlist.json +++ b/supabase/chain-mirror-allowlist.json @@ -1,6 +1,13 @@ { "_comment": "Reviewed, documented divergence between what supabase/migrations/** BUILDS and what supabase/schema.sql DESCRIBES. Consumed by scripts/check-chain-mirror-parity.ts (npm run check:chain-mirror-parity) in the CI Migration replay job. This is NOT supabase/drift-allowlist.json and must never be merged with it: that file allowlists live-vs-mirror divergence, and an entry there blinds the live drift alarm. An entry here only says 'the chain and the mirror are knowingly different in this one place'.", - "_status": "EMPTY BY DESIGN. The gate ships report-only because the existing divergences cannot be enumerated offline - docs/database-drift-detection.md backlog item 10 already records ~13 chain-vs-mirror keys plus storage buckets that only schema.sql creates, but nobody has measured the current set. The first CI run prints exactly what belongs here; committing that list and adding --strict is the follow-up. Do not pre-seed guesses.", + "_status": "MEASURED 2026-09-02 from the gate's first real CI run (Migration replay, run 33599464239). The predicted ~13 chain-vs-mirror keys did NOT appear: the structural comparison found zero divergence across tables, views, functions, indexes, policies, constraints, triggers and storage buckets. Only two extensions differed, and only one of them belongs here. pg_net is provisioned by the Supabase emulator image - no migration creates it and schema.sql never declares it - so it is the image asymmetry this file exists for. pg_cron is NOT allowlisted and must not be: 20260901033250_enable_staging_privacy_retention_schedules.sql creates it and supabase/schema.sql does not declare it, which is a real mirror gap and exactly the finding this gate exists to surface. Fixing it means editing schema.sql and regenerating the drift manifest, which needs Docker, so it is owner work rather than something to silence here.", "_entry_shape": "{ category: one of extensions|tables|views|functions|indexes|policies|constraints|triggers|storage_buckets, kind: missing_live|unexpected_live|mismatch, key: the snapshot key, reason: why this divergence is accepted rather than fixed (> 20 chars) }", - "entries": [] + "entries": [ + { + "category": "extensions", + "kind": "unexpected_live", + "key": "pg_net", + "reason": "Provisioned by the Supabase local emulator image that builds the chain side, not by any migration: no file under supabase/migrations/** creates pg_net and supabase/schema.sql never declares it, so neither side of the comparison asked for it. This is the two-different-images asymmetry documented in the script header, not a chain-vs-mirror divergence. Observed in the gate's first real CI run on 2026-09-02." + } + ] } From 3359f4361e81e19e847dde08146324ff943c9f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 08:45:54 +0000 Subject: [PATCH 6/7] chore(drift): regenerate the repo-awareness snapshot for the runbook edit Same cause as the tenancy branch: an earlier merge here restored the two generated snapshots to their merged state to sidestep the cross-PR conflict churn, which also discarded the regeneration this branch's own section in docs/database-drift-detection.md requires. check:repo-awareness-snapshot would have gone red on "documentation differs from the repository". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sjekpEw82gMp57C8xzSxZ --- data/repo-awareness-snapshot.json | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index a3f595d019..416ebd063f 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v2", "captured_revision": { - "sha": "81fbd8b7cabb2f22875b144a47eeb9530c892c20", - "committed_at": "2026-09-02T07:57:26+00:00" + "sha": "b466a3723bbd50847a76fb40792bc5b9d4552bdc", + "committed_at": "2026-09-02T08:23:14+00:00" }, "routes": { "modes": [ @@ -3400,16 +3400,6 @@ "section": "root", "catalogued": true }, - { - "path": "docs/scripts-index.md", - "section": "root", - "catalogued": true - }, - { - "path": "docs/scripts-index.md", - "section": "root", - "catalogued": true - }, { "path": "docs/search-chrome-behaviour.md", "section": "root", @@ -4427,8 +4417,8 @@ } ], "counts": { - "documents": 578, - "catalogued": 113, + "documents": 576, + "catalogued": 111, "uncatalogued": 465, "sections": 21 } From 4ff45a8415c598add2869cf70a47a072a3676617 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 11:57:31 +0000 Subject: [PATCH 7/7] chore(drift): regenerate the repo-awareness snapshot after merging main The merge brought in new documents; the snapshot is generated, so it is refreshed with npm run snapshot:repo-awareness rather than hand-edited. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015sjekpEw82gMp57C8xzSxZ --- data/repo-awareness-snapshot.json | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 27ea9e9903..5e32f6fe7a 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v2", "captured_revision": { - "sha": "0c351304f81595870965b7662b2df65d40f1dec7", - "committed_at": "2026-09-02T09:16:52+00:00" + "sha": "824d483d4bdd94d47f26228ee8e99206b2b04bb6", + "committed_at": "2026-09-02T11:53:32+00:00" }, "routes": { "modes": [ @@ -3415,16 +3415,6 @@ "section": "root", "catalogued": true }, - { - "path": "docs/scripts-index.md", - "section": "root", - "catalogued": true - }, - { - "path": "docs/scripts-index.md", - "section": "root", - "catalogued": true - }, { "path": "docs/search-chrome-behaviour.md", "section": "root", @@ -4447,8 +4437,8 @@ } ], "counts": { - "documents": 582, - "catalogued": 113, + "documents": 580, + "catalogued": 111, "uncatalogued": 469, "sections": 21 }