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
73 changes: 73 additions & 0 deletions docs/audit/live-drift-forensics-2026-08.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1950,3 +1950,76 @@ the `SUPABASE_ACCESS_TOKEN` secret of `#183`), or a service-role RPC listing ver
with its own window). Queued as its own ledger item from this session; until it is fixed the weekly job
will stay red on that step alone and the pinned issue will not self-close — **the drift block, which is
what the issue was opened for, is clear.**

## Alignment-step repair — 2026-08-20 (repo-side; production deploy still owed)

_Follow-on from Phase 6.2 step 6. Repo-only session: no hosted mutation, no provider gate run. The
three GitHub reads (open-PR list, issue #1963, live-drift run `32378402265`) were owner-requested._

### The finding restated, re-measured on `main`

live-drift run [`32378402265`](https://github.com/BigSimmo/Database/actions/runs/32378402265),
2026-08-20T14:09:03Z, `main`, weekly cron. Step conclusions:

```
Compare live schema drift: success
Align migration history for Supabase Preview: failure
```

Compare step, decisive lines:

```
Compared 6 extensions, 38 tables, 1 views, 93 functions, 210 indexes, 48 policies, 170 constraints, 26 triggers, 2 storage_buckets against live.
No unexpected schema drift between live and supabase/schema.sql.
```

Alignment step, decisive line:

```
Unable to read remote schema_migrations via Accept-Profile (status 406: {"code":"PGRST106","details":null,"hint":"Only the following schemas are exposed: public, graphql_public","message":"Invalid schema: supabase_migrations"})
```

So the drift block has now been empty for **two consecutive runs** (`32251326536` on the 6.2 branch,
`32378402265` on `main`), and issue #1963 is still open solely because a sibling step cannot read a
table it was never able to read. `#316`'s finding set stays empty.

### Fix: least-privilege RPC, not a widened API surface and not a new credential

`20260820120000_migration_history_versions_rpc.sql` adds
`public.migration_history_versions()` — `stable`, `security definer`, `set search_path to ''`,
dynamic read guarded by `to_regclass`, returning `{probe, versions}` for every history row.
`revoke ... from public, anon, authenticated` + `grant ... to service_role`, exactly the
`schema_drift_snapshot()` pattern (`20260706200000` / `20260818090000`).

Two alternatives were rejected and are recorded so the choice is not re-litigated:

| Option | Why not |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Expose `supabase_migrations` to the Data API | Widens the public PostgREST surface of a clinical project for one weekly read, and lives in dashboard config, not in git |
| Read via the management API + `SUPABASE_ACCESS_TOKEN` | Puts an account-scoped token into CI secrets — far broader authority than the read needs; also still blocked on `#183` |

`scripts/check-migration-history-alignment.ts` now tries the RPC first and falls back to the old
Accept-Profile read **only** when the function itself is absent (404 / `PGRST202`). Every other outcome
raises, including `probe: no_history_table` — a check that reports "aligned" because it could not look
is worse than the red job it replaces. When neither path works, the error names the remedy.

### Repo-side proof

- `npm run drift:manifest` — full scratch replay of `supabase/schema.sql` into
`supabase/postgres:17.6.1.127`: "Replay complete in 58s". This executes the new function body in a
real Postgres, so the SQL is proven, not merely reviewed. Manifest now carries **94** functions
(was 93) with `public.migration_history_versions()` at
`acl: ["postgres=X/postgres", "service_role=X/postgres"]` — least privilege confirmed by replay,
no `PUBLIC` execute. `schema_sha256` `6fe4883e03fa…`.
- `tests/migration-history-alignment.test.ts` — 7 tests: RPC preferred and Accept-Profile never sent;
fallback only on an absent function; unexpected RPC failure surfaces rather than falling back;
`no_history_table` is an error; migration-vs-`schema.sql` byte parity; read-only + service-role-only
shape.

### What is still owed

The migration is **not deployed**. D4 is OFF, so merging does not apply it, and until it is applied
`check:drift` will report `migration_history_versions` as a missing function — i.e. merging before the
window trades one red for another. **Deploy from the branch first, then merge**, which is the order
Phase 4 used (§Phase 4 completion). Staging needs the same migration by the Phase 2 method to hold the
parity Phase 4 restored.
36 changes: 36 additions & 0 deletions docs/database-drift-detection.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,6 +143,42 @@ entries, which is how a wrong seed is caught; against staging every
`migration_history` entry reads stale by design (staging's chain was replayed
with statements), so never run `--prune-stale` there.

## Migration-history alignment (`npm run check:migration-history`)

A second, separate step of `.github/workflows/live-drift.yml`, added by Phase 0
(PR #1939). It compares the versions in `supabase/migrations` against the versions
recorded in live `supabase_migrations.schema_migrations` and fails when live holds
a version with no local file — the state that makes a hosted Supabase Preview
branch fail with "Remote migration versions not found in local migrations
directory". It is not the probe above: the probe asks whether an applied version
executed its DDL, this asks whether an applied version exists in the repo at all.

**It could never pass on this project until 2026-08-20.** The original
implementation read the history table straight through PostgREST with
`Accept-Profile: supabase_migrations`, and this project has never exposed that
schema to the Data API, so the read returned `406 PGRST106` every time. The defect
stayed hidden because the drift comparison ran first and always failed, leaving
this step `skipped`; Phase 6.2 cleared the last drift finding on 2026-08-19 and the
step ran for the first time ever, becoming the sole reason the job still concluded
`failure` — and therefore the sole reason pinned issue #1963 stayed open against a
clean database.

The read now goes through **`public.migration_history_versions()`** (migration
`20260820120000`), a `stable` `security definer` function with `search_path` pinned
to `''`, granted to `service_role` only, that returns `{probe, versions}` for every
row of the history table. It is deliberately the smallest possible authority:
exposing `supabase_migrations` to the Data API would widen the public API surface
of a clinical project for one weekly read, and routing through the management API
would put an account-scoped access token into CI.

The Accept-Profile read is retained as a fallback for any environment that does
expose the schema, and is tried **only** when the RPC itself is absent. Every other
outcome is an error, including a database with no history table at all
(`probe: no_history_table`) — a check that reports "aligned" because it could not
look would be worse than the red job it replaced. When neither path works the
failure names the remedy: apply `20260820120000` through the normal linked
migration workflow. Pinned by `tests/migration-history-alignment.test.ts`.

## Guard-migration contract

**Rule (also in `AGENTS.md`, "Supabase project safety"): any mark-applied
Expand Down
17 changes: 16 additions & 1 deletion docs/database-remediation-coordination.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,7 +128,22 @@ open PR #2130 queues 7 more requests but carries **no** reconcile transaction. O
link a dedicated worktree for production reads and `supabase unlink` after; `supabase db query
--linked --project-ref <ref>` works read-only via the management API without a DB password.

**Where the programme stands after Phase 6.2 (2026-08-19, later the same day).** Drift is **green on both tiers**: the fifteen `migration_history` rows now carry `validation` guards, live-drift run `32251326536` (dispatched on the 6.2 branch) reports `No unexpected schema drift` with all 20 history rows allowed, and the staging comparison is green with the 20 production-scoped entries reading stale as designed. `#316`'s finding set is empty for the first time since 2026-07-26. **Two things still need the owner, not a worker:** (1) the live-drift job is red on its `Align migration history` step alone — `check:migration-history` cannot read `supabase_migrations` over PostgREST (PGRST106), a Phase 0 step that had never run before because drift always failed first; until it is fixed (expose the schema read-only / wire `SUPABASE_ACCESS_TOKEN` `#183` and use `supabase migration list` / add a versions RPC) the weekly job stays red and pinned issue #1963 will not self-close even though its drift block is empty; (2) PITR is still off on production (Phase 4 escalation). **Next dispatches:** merge the 6.2 PR (its CI `Migration replay` and Supabase Preview are the last chain proofs), then Phase 5 close-out (after-EXPLAIN set, `#231` re-test on healthy latency, `check:production-readiness`), then one serialized `issues:reconcile`. Every future migration still needs its own approved window and its own `db push` (D4 OFF).
**Where the programme stands after Phase 6.2 (2026-08-19, later the same day).** Drift is **green on both tiers**: the fifteen `migration_history` rows now carry `validation` guards, live-drift run `32251326536` (dispatched on the 6.2 branch) reports `No unexpected schema drift` with all 20 history rows allowed, and the staging comparison is green with the 20 production-scoped entries reading stale as designed. `#316`'s finding set is empty for the first time since 2026-07-26. **Two things still need the owner, not a worker:** (1) the live-drift job is red on its `Align migration history` step alone — `check:migration-history` cannot read `supabase_migrations` over PostgREST (PGRST106), a Phase 0 step that had never run before because drift always failed first; until it is fixed (expose the schema read-only / wire `SUPABASE_ACCESS_TOKEN` `#183` and use `supabase migration list` / add a versions RPC) the weekly job stays red and pinned issue #1963 will not self-close even though its drift block is empty; (2) PITR is still off on production (Phase 4 escalation).

**Update 2026-08-20 — the alignment step is fixed in the repo, not yet on production.** Drift stayed
empty on a second run (`32378402265`, `main`, weekly cron: `Compare live schema drift: success` /
`No unexpected schema drift`), confirming Phase 6.2 held. The PGRST106 defect was repaired by
`20260820120000_migration_history_versions_rpc.sql` — a `security definer`, service-role-only read of
the history version list — plus an RPC-first rewrite of
`scripts/check-migration-history-alignment.ts`, `schema.sql` mirror, regenerated manifest (94
functions) and `tests/migration-history-alignment.test.ts`. Exposing `supabase_migrations` to the Data
API and adding a management-API token to CI were both considered and rejected (forensics §Alignment-step
repair). **Deploy order matters: `db push` from the branch FIRST, then merge** — D4 is OFF, so merging
alone applies nothing and would make `check:drift` report a missing function in the meantime. Staging
needs the same migration by the Phase 2 method. Until that window runs, the weekly job stays red on
this one step and #1963 will not self-close.

**Next dispatches:** merge the 6.2 PR (its CI `Migration replay` and Supabase Preview are the last chain proofs), then Phase 5 close-out (after-EXPLAIN set, `#231` re-test on healthy latency, `check:production-readiness`), then one serialized `issues:reconcile`. Every future migration still needs its own approved window and its own `db push` (D4 OFF).

**Where the programme stands after Phase 4 (2026-08-19).****Where the programme stands after Phase 4 (2026-08-19).** The index track of `#316` is closed on both
tiers and staging is at full parity, so the remaining live-drift findings are exactly one category:
Expand Down
110 changes: 90 additions & 20 deletions scripts/check-migration-history-alignment.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,9 +16,24 @@ loadEnvConfig(process.cwd());
* This script prints remote-only / local-only versions and exits 1 when any
* remote-only versions remain. It is intended for workflow_dispatch / live
* alignment checks (uses service-role secrets).
*
* TRANSPORT: the history table lives in the `supabase_migrations` schema, which
* this project does not expose to the Data API — a direct PostgREST read returns
* 406 PGRST106, so the original Accept-Profile read could never succeed here. It
* went unnoticed until 2026-08-19 because the live-drift workflow's drift
* comparison always failed first and skipped this step; once Phase 6.2 cleared
* the last drift finding, this became the sole reason the job stayed red (and
* pinned issue #1963 stayed open) against a clean database. The read now goes
* through public.migration_history_versions(), a service-role-only security
* definer RPC (migration 20260820120000), with the Accept-Profile read retained
* as a fallback for any environment that does expose the schema. See
* docs/database-drift-detection.md and docs/audit/live-drift-forensics-2026-08.md.
*/

export const MIGRATION_HISTORY_VERSIONS_MIGRATION = "20260820120000_migration_history_versions_rpc.sql";

type RemoteRow = { version: string; name: string | null };
type RemoteRead = { rows: RemoteRow[]; source: "rpc" | "accept-profile" };

function localMigrationVersions(migrationsDir: string): string[] {
return readdirSync(migrationsDir)
Expand All@@ -30,29 +45,81 @@ function localMigrationVersions(migrationsDir: string): string[] {
.sort();
}

async function fetchRemoteVersions(url: string, serviceKey: string): Promise<RemoteRow[]> {
// Read supabase_migrations via Accept-Profile when the project exposes that
// schema to the Data API. Preview alignment itself only needs local files to
// cover remote versions; this check is diagnostic for live history drift.
const profileResponse = await fetch(`${url}/rest/v1/schema_migrations?select=version,name&order=version.asc`, {
function authHeaders(serviceKey: string): Record<string, string> {
return { apikey: serviceKey, Authorization: `Bearer ${serviceKey}` };
}

/**
* Preferred path: the service-role-only RPC. Returns null (rather than throwing)
* only when the function itself is absent, so a project that has not yet applied
* the migration can still fall back to Accept-Profile. Every other failure —
* including a live database with no history table — is a real error and throws.
*/
async function fetchViaRpc(url: string, serviceKey: string): Promise<RemoteRow[] | null> {
const response = await fetch(`${url}/rest/v1/rpc/migration_history_versions`, {
method: "POST",
signal: AbortSignal.timeout(10_000),
headers: {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
"Accept-Profile": "supabase_migrations",
},
headers: { ...authHeaders(serviceKey), "Content-Type": "application/json" },
body: "{}",
});
if (profileResponse.ok) {
return (await profileResponse.json()) as RemoteRow[];

if (!response.ok) {
const text = await response.text();
if (response.status === 404 || /PGRST202|could not find the function|schema cache/i.test(text)) {
return null;
}
throw new Error(`migration_history_versions() RPC failed (status ${response.status}: ${text.slice(0, 240)})`);
}

const profileText = await profileResponse.text();
const payload = (await response.json()) as { probe?: string; versions?: RemoteRow[] } | null;
if (!payload || typeof payload !== "object") {
throw new Error("migration_history_versions() returned an unexpected payload");
}
if (payload.probe !== "ok") {
throw new Error(
`migration_history_versions() reports probe "${payload.probe ?? "unknown"}": the target database has no ` +
`supabase_migrations.schema_migrations table, so migration history cannot be compared. ` +
`Check that this is the intended project.`,
);
}
return payload.versions ?? [];
}

/** Legacy path, kept for any project that exposes supabase_migrations to PostgREST. */
async function fetchViaAcceptProfile(url: string, serviceKey: string): Promise<RemoteRow[]> {
const response = await fetch(`${url}/rest/v1/schema_migrations?select=version,name&order=version.asc`, {
signal: AbortSignal.timeout(10_000),
headers: { ...authHeaders(serviceKey), "Accept-Profile": "supabase_migrations" },
});
if (response.ok) {
return (await response.json()) as RemoteRow[];
}
const text = await response.text();
throw new Error(
`Unable to read remote schema_migrations via Accept-Profile ` +
`(status ${profileResponse.status}: ${profileText.slice(0, 240)})`,
`(status ${response.status}: ${text.slice(0, 240)})`,
);
}

export async function fetchRemoteVersions(url: string, serviceKey: string): Promise<RemoteRead> {
const viaRpc = await fetchViaRpc(url, serviceKey);
if (viaRpc) {
return { rows: viaRpc, source: "rpc" };
}

try {
return { rows: await fetchViaAcceptProfile(url, serviceKey), source: "accept-profile" };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Migration history is unreadable on this project. public.migration_history_versions() is not ` +
`available — apply supabase/migrations/${MIGRATION_HISTORY_VERSIONS_MIGRATION} through the normal ` +
`linked migration workflow (an approved window plus \`supabase db push\`; the Supabase GitHub ` +
`auto-deploy is off) — and the direct read also failed. ${detail}`,
);
}
}

async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
Expand All@@ -64,12 +131,12 @@ async function main() {
const local = new Set(localMigrationVersions(migrationsDir));
console.log(`Local migration versions: ${local.size}`);

const remoteRows = await fetchRemoteVersions(url, serviceKey);
const { rows: remoteRows, source } = await fetchRemoteVersions(url, serviceKey);
const remote = new Set(remoteRows.map((row) => row.version));
const remoteOnly = [...remote].filter((version) => !local.has(version)).sort();
const localOnly = [...local].filter((version) => !remote.has(version)).sort();

console.log(`Remote migration versions: ${remote.size}`);
console.log(`Remote migration versions: ${remote.size} (read via ${source})`);
console.log(`Remote-only (Preview blockers): ${remoteOnly.length}`);
for (const version of remoteOnly) {
const row = remoteRows.find((item) => item.version === version);
Expand All@@ -94,7 +161,10 @@ async function main() {
console.log("Migration history alignment OK: every remote version exists locally.");
}

main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
const invokedDirectly = process.argv[1] && /check-migration-history-alignment\.(ts|mts|js)$/.test(process.argv[1]);
if (invokedDirectly) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}
14 changes: 11 additions & 3 deletions supabase/drift-manifest.json
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
{
"generated_at": "2026-08-18T18:15:50.121Z",
"generated_at": "2026-08-20T15:53:04.540Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
"schema_sha256": "328677d1c6f3e0136ca108e8117a73051553a2387b078836001e8cdc96f4c2d3",
"replay_seconds": 21,
"schema_sha256": "6fe4883e03fa662dc90e09f3cfd8d35ab9fcf965bfe87bad64db73f1fab5986e",
"replay_seconds": 58,
"snapshot": {
"views": [
{
Expand DownExpand Up@@ -7139,6 +7139,14 @@
"def_hash": "8248e8d1aec23bbad35aca0ddc4dc94d",
"signature": "public.match_documents_for_query(text,integer,uuid)"
},
{
"acl": [
"postgres=X/postgres",
"service_role=X/postgres"
],
"def_hash": "4e8e4f9ee726a5eef77d6d6a646fe787",
"signature": "public.migration_history_versions()"
},
{
"acl": [
"postgres=X/postgres",
Expand Down
Loading
Loading