Retention hotfix: drop published_side, derive the schema contract, break the silence

`createCollector.onPublished` set `published_side` beside `published`.
`published_side` is not a model_snapshots column. supabase-js declares the
UNION of row keys in the `columns=` parameter, so one invalid key made
PostgREST reject the ENTIRE batch with a 400 — every sport, every cycle.
Retention is best-effort, so nothing surfaced. Confirmed in edge logs.

The field was redundant as well as invalid: `side` is already on the row.
Deleted rather than added to the schema — a column would preserve an
accidental artifact.

Three things missed it, and each is now closed:

1. WRONG SHAPE INSPECTED. The manual check sampled the collector after
   onGraded only and never called onPublished, so the offending key was
   not yet on the row. It read a pre-publication shape and reported the
   final outbound shape as clean. The new test captures the array actually
   handed to .upsert(), after the full production call order.

2. NO CONTRACT. Every retention test injects a permissive fake client that
   accepts any column set, so 381 suites proved the logic and never once
   compared a row against the database. The contract is now DERIVED — the
   migration chain applied to a disposable postgres, read out of
   information_schema (scripts/generate-schema-contract.js). A
   hand-maintained list would be a second opinion about the schema, and a
   second opinion is what let this through. scripts/verify-schema-contract.js
   checks the contract still describes a live database.

3. SILENT FAILURE. A failed batch reached one console.log. It now emits a
   high-severity structured event carrying sport, snapshot id, stage,
   error, code_sha and timestamp. Best-effort semantics are unchanged —
   the product continues and says so — but the failure is observable.
   `skipped` (no database configured) is not a failure and does not alert.

Teeth, each with the injection verified present before the run:
  - published_side back into the final payload -> 4 tests fail; restored
    byte-identically (sha 6a0ced7c52134135 both sides)
  - settled_at (a REAL contract column) -> accepted, so the guard
    discriminates by contract membership, not by novelty
  - alert block deleted -> 3 tests fail; restored byte-identically

Model and product behaviour untouched: analyzeViaEngine1,
probabilityEstimator, gradeSlateService, lineageCanaryConfig all unchanged.
Lineage stays OFF. Net source change is one behavioural line plus the alert.

382 suites / 5,094 tests pass. web tsc exit 0 (zero web paths touched).

Measurement blackout recorded, NOT backfilled: last good retention write
2026-08-27T19:08:32Z; ceaa896 started 21:16:41Z; the 22:00 UTC cycle ran
(ledger wrote 22:05:02) and persisted zero snapshot rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
Kev
2026-08-27 18:53:37 -04:00
parent ceaa896f77
commit 35da190f2c
6 changed files with 428 additions and 1 deletions
+56
View File
@@ -0,0 +1,56 @@
'use strict';
/**
* Regenerate supabase/schema/model_snapshots.columns.json from the MIGRATION
* CHAIN, by applying every committed migration to a disposable postgres and
* reading information_schema.
*
* The contract must be DERIVED, never hand-maintained: a hand-written list is
* a second opinion about the schema, and a second opinion is exactly what let
* `published_side` reach production. Deriving it from the same migrations the
* release verification applies means the list cannot drift from the release.
*
* docker run -d --name schema-gen -e POSTGRES_PASSWORD=test -e POSTGRES_DB=v postgres:15-alpine
* node scripts/generate-schema-contract.js schema-gen
*
* Drift against a LIVE database is a separate question — see
* scripts/verify-schema-contract.js.
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const CONTAINER = process.argv[2];
const TABLE = process.env.SCHEMA_TABLE || 'model_snapshots';
const OUT = path.join(__dirname, '..', 'supabase', 'schema', `${TABLE}.columns.json`);
function psql(sql) {
return execFileSync('docker', ['exec', CONTAINER, 'psql', '-U', 'postgres', '-d', 'v', '-tAc', sql],
{ encoding: 'utf8' }).trim();
}
function main() {
if (!CONTAINER) throw new Error('usage: node scripts/generate-schema-contract.js <container>');
const cols = psql(
`select string_agg(column_name, ',' order by column_name)`
+ ` from information_schema.columns`
+ ` where table_schema='public' and table_name='${TABLE}'`
+ ` and is_generated='NEVER' and identity_generation is null;`,
).split(',').filter(Boolean);
if (cols.length === 0) throw new Error(`no columns found for ${TABLE} — was the migration chain applied?`);
const prev = fs.existsSync(OUT) ? JSON.parse(fs.readFileSync(OUT, 'utf8')) : {};
const doc = {
...prev,
table: TABLE,
generated_by: 'scripts/generate-schema-contract.js',
derived_from: 'supabase/migrations/*.sql applied in order to a disposable postgres:15-alpine',
column_count: cols.length,
columns: cols,
};
fs.writeFileSync(OUT, `${JSON.stringify(doc, null, 2)}\n`);
console.log(`wrote ${OUT} — ${cols.length} columns`);
}
if (require.main === module) main();
+49
View File
@@ -0,0 +1,49 @@
'use strict';
/**
* DRIFT CHECK — compare the committed schema contract against a LIVE database.
*
* The unit guard proves the retention writer stays inside the contract. This
* proves the CONTRACT still describes reality. Both are needed: a correct
* writer against a stale contract is the same failure wearing a different hat.
*
* SUPABASE_URL=... SUPABASE_SERVICE_KEY=... node scripts/verify-schema-contract.js
*
* Exit 0 when the live table is a superset of the contract (safe: every column
* the writer may emit exists). Exit 1 when a contract column is MISSING live —
* that is the condition that breaks retention.
*/
const fs = require('fs');
const path = require('path');
const { createClient } = require('@supabase/supabase-js');
const TABLE = process.env.SCHEMA_TABLE || 'model_snapshots';
async function main() {
const contract = JSON.parse(fs.readFileSync(
path.join(__dirname, '..', 'supabase', 'schema', `${TABLE}.columns.json`), 'utf8'));
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key) throw new Error('SUPABASE_URL + service key required');
const sb = createClient(url, key, { auth: { persistSession: false } });
// One row is enough to learn the live column set from the response shape.
const { data, error } = await sb.from(TABLE).select('*').limit(1);
if (error) throw new Error(`live read failed: ${error.message}`);
if (!data || data.length === 0) throw new Error(`${TABLE} is empty — cannot infer live columns`);
const live = new Set(Object.keys(data[0]));
const missing = contract.columns.filter((c) => !live.has(c));
const extra = [...live].filter((c) => !contract.columns.includes(c)).sort();
console.log(`contract columns : ${contract.columns.length}`);
console.log(`live columns : ${live.size}`);
console.log(`live-only (informational, safe): ${extra.length ? extra.join(', ') : 'none'}`);
console.log(`MISSING LIVE (breaks retention): ${missing.length ? missing.join(', ') : 'none'}`);
process.exit(missing.length ? 1 : 0);
}
if (require.main === module) {
main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
}