Files
vyndr/tests/unit/retentionSchemaContract.test.js
T
builtbykev 11277a1b99 Materialization truth: a completed write is not a complete cohort
Transport truth says every intended write succeeded. Materialization truth says
every identity that should exist actually exists. The retention writer could
only report the first, and the gap is not theoretical.

THE CONFLICT IDENTITY, traced to the real index:
  model_snapshots_cycle_prop_uniq UNIQUE (snapshot_id, player_key, stat, line, side)
written by `upsert(..., { onConflict: same five columns, ignoreDuplicates: true })`.
Measured in production: no key column is ever NULL (0 of 328,262 rows), so
NULLS DISTINCT never applies and the identity is plain column equality. `line`
is an unconstrained numeric, so identity normalises it — 0.5 in and "0.50" out
must not read as two identities for one stored row.

PRIOR-CYCLE COLLISION IS IMPOSSIBLE. `snapshot_id` is in the identity and is a
fresh UUID per cycle, so no row can be suppressed by an earlier cycle.
Append-only chronology across cycles is safe, and `captured_at` is not in the
identity, so a cohort cannot be split by timing.

INTRA-CYCLE COLLISION IS REAL, AND WE CAUSED IT. `canonical_event_id` is NOT in
the identity. A doubleheader — same hitter, same stat, same line, two genuinely
different games — is ONE identity. Demonstrated through the real collector: 4
outbound rows, 2 distinct identities, 2 rows discarded by ignoreDuplicates with
no error, `written` counting all 4 and the terminal status reading COMPLETE.
Before event-aware dedupe the second game was dropped before grading, so the
collision could not arise; that fix moved the loss downstream into retention.

The conflict identity is NOT changed here — that is a separate decision with its
own before/after. This makes the loss visible instead of silent.

EXPECTED vs ACTUAL. `expectedMaterialization(rows)` derives the identity set
from the FINAL outbound payload using the exact database identity — never from
`attempted`, which counts rows sent, not identities that can exist.
`reconcileMaterialization` compares SETS, not counts: two sets of equal size can
still differ, and a cohort that swapped one identity for another passes every
count test ever written. A collision passes set equality by construction (the
discarded row was never in the expected set) while real rows were lost, so
collision_count > 0 fails the cohort on its own.

A cohort is evidence-complete only when transport is COMPLETE, missing = 0,
extra = 0, and collisions = 0.

OBSERVABILITY stayed minimal. `last_retention` was already PER SPORT (a Map
keyed by sport), so no fix was needed there and the route is UNCHANGED — the new
fields ride the existing entry: outbound_rows, expected_materialized_count,
outbound_collision_count, expected_identity_digest. Counts and a digest only,
never the identities, which carry player names. The expected set is the one
materialization fact unrecoverable from the database afterwards, which is why it
is the only thing recorded at runtime.

A collision leaves transport COMPLETE, so the existing failure alert could never
see it. It now has its own high-severity alert naming the counts, the cycle and
the build, and says the cohort is not evidence-complete.

Seven teeth, each injection verified present, against a GREEN baseline of 63:
  1 attempted===written as evidence completeness   -> 1 fail
  2 COUNT(*) equality instead of set equality       -> 1 fail
  3 snapshot_id dropped from expected identity      -> 4 fail
  4 unexpected collision allowed to qualify         -> 1 fail
  5 single global last_retention slot               -> 2 fail
  6 partial chunk failure treated as usable         -> 3 fail
  7 collision loses its announcement                -> 1 fail
Restored byte-identically (retention 742f116473d97f49, snapshot 81129facbabeb280).

Three brittle assertions repaired, with the reason recorded: two windowed on a
byte count that a neighbouring block outgrew — a test failing because of its
neighbour, not its subject — now windowed to syntactic landmarks; and one
counted TERMINAL.COMPLETE occurrences, which a legitimate comparison
incremented. It now asserts one DECISION and one READ.

persist() and createCollector are BYTE-IDENTICAL. onConflict and
ignoreDuplicates appear in the diff only as prose. Model, event, ledger,
calibration, chain, lineage config, and the status route: UNCHANGED. Zero
lineage/publication files, zero cacheSet changes, zero web paths. Lineage OFF.

Schema contract unchanged: release 64, prod 67, prod-only 3 (debt, not
authorized), missing in prod 0.

384 suites / 5,144 tests pass. web tsc exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
2026-08-27 19:44:56 -04:00

226 lines
9.9 KiB
JavaScript

'use strict';
/**
* RETENTION SCHEMA CONTRACT — the guard that would have caught the outage.
*
* WHAT HAPPENED. `createCollector.onPublished` set `published_side` alongside
* `published`. `published_side` is not a `model_snapshots` column, supabase-js
* declares the UNION of row keys in the `columns=` parameter, and PostgREST
* rejected the entire batch with a 400 — for every sport, silently, because
* retention is best-effort. Verified in production edge logs.
*
* WHY 381 GREEN SUITES MISSED IT. Every retention test injects a permissive
* fake client — `{ from: () => ({ upsert: async () => ({ error: null }) }) }` —
* which accepts any column set. The suite proved the logic and never once
* compared a row against the database contract.
*
* WHY THE FIRST MANUAL CHECK ALSO MISSED IT. It sampled the collector after
* `onGraded` only and never called `onPublished`, so the offending key was not
* yet on the row. It inspected a PRE-PUBLICATION shape and reported the FINAL
* outbound shape as clean.
*
* So this test does two things the old ones could not:
* 1. it validates the payload ACTUALLY HANDED TO `.upsert()`, captured by a
* spy, after the full production call order including `onPublished`;
* 2. it validates against a contract DERIVED from the migration chain, not a
* hand-maintained list.
*/
const path = require('path');
const fs = require('fs');
const retention = require('../../src/services/retentionService');
const ROOT = path.resolve(__dirname, '..', '..');
const CONTRACT = JSON.parse(fs.readFileSync(
path.join(ROOT, 'supabase/schema/model_snapshots.columns.json'), 'utf8',
));
/** Captures the exact array supabase-js would send. */
function spyClient() {
const seen = [];
return {
seen,
client: {
from: (table) => ({
upsert: async (rows, opts) => { seen.push({ table, rows, opts }); return { error: null }; },
}),
},
};
}
const CTX = {
snapshotId: '00000000-0000-0000-0000-000000000000',
capturedAt: '2026-08-27T22:00:00Z',
gameDate: '2026-08-27',
gameIdFor: () => 'mlb:2026-08-27:BostonRedSox@MiamiMarlins',
};
const BASE = {
player: 'Aaron Judge', stat_type: 'hits', line: 0.5, sport: 'mlb', book: 'draftkings',
canonical_event_id: 'mlb:gamepk:823825', event_identity_source: 'MLB_STATSAPI_GAMEPK',
event_identity_method: 'CANONICAL', event_identity_version: 'evid@1', event_occurrence: 1,
};
const OVER = { ...BASE, direction: 'over', grade: 'B', confidence: 61, p_win: 0.61 };
const UNDER = { ...BASE, direction: 'under', grade: 'C', confidence: 39, p_win: 0.39 };
/** The FULL production call order — onGraded for both sides, then onPublished. */
function finalOutboundRows() {
const c = retention.createCollector(CTX);
c.onGraded(BASE, [OVER, UNDER]);
c.onPublished(BASE, OVER);
return c.rows;
}
describe('the contract itself is derived, not hand-written', () => {
test('it declares its generator and its derivation', () => {
expect(CONTRACT.table).toBe('model_snapshots');
expect(CONTRACT.generated_by).toBe('scripts/generate-schema-contract.js');
expect(CONTRACT.derived_from).toMatch(/supabase\/migrations/);
expect(fs.existsSync(path.join(ROOT, 'scripts/generate-schema-contract.js'))).toBe(true);
expect(fs.existsSync(path.join(ROOT, 'scripts/verify-schema-contract.js'))).toBe(true);
});
test('it is non-trivial and self-consistent', () => {
expect(CONTRACT.columns.length).toBe(CONTRACT.column_count);
expect(CONTRACT.columns.length).toBeGreaterThan(50);
expect(CONTRACT.columns).toContain('published');
expect(CONTRACT.columns).toContain('canonical_event_id');
// The offending field must NOT be in the contract — that is the fact.
expect(CONTRACT.columns).not.toContain('published_side');
});
test('known production drift is recorded rather than hidden', () => {
const d = CONTRACT.known_production_drift;
expect(d.columns_in_production_not_in_migrations).toEqual(
expect.arrayContaining(['quarantine_reason', 're_settled_at', 'settlement_source']),
);
// The migration-derived set is the stricter of the two, so a writer inside
// it is valid against production as well.
expect(d.explanation).toMatch(/stricter/);
});
});
describe('FINAL OUTBOUND payload is within the schema contract', () => {
test('the captured upsert payload uses only real columns', async () => {
const spy = spyClient();
await retention.persist(finalOutboundRows(), { getClient: () => spy.client });
expect(spy.seen.length).toBeGreaterThan(0);
const call = spy.seen[0];
expect(call.table).toBe('model_snapshots');
// supabase-js sends the UNION of keys across the batch — validate the union.
const union = new Set();
for (const r of call.rows) for (const k of Object.keys(r)) union.add(k);
const invalid = [...union].filter((k) => !CONTRACT.columns.includes(k)).sort();
expect(invalid).toEqual([]);
});
test('the payload is captured AFTER onPublished, not before', async () => {
// The pre-publication shape is what the earlier manual check inspected.
const pre = retention.createCollector(CTX);
pre.onGraded(BASE, [OVER, UNDER]);
const preKeys = new Set(pre.rows.flatMap((r) => Object.keys(r)));
const post = finalOutboundRows();
const postKeys = new Set(post.flatMap((r) => Object.keys(r)));
// Both must be valid; the point is that this test exercises the later one.
for (const set of [preKeys, postKeys]) {
expect([...set].filter((k) => !CONTRACT.columns.includes(k))).toEqual([]);
}
// And onPublished must actually have marked a row, or the test is vacuous.
expect(post.filter((r) => r.published === true)).toHaveLength(1);
expect(post.filter((r) => r.published === false)).toHaveLength(1);
});
test('every declared key is present on EVERY row', async () => {
// A key on only some rows is dropped for the whole batch by PostgREST, so
// a ragged batch is its own defect class.
const rows = finalOutboundRows();
const union = new Set(rows.flatMap((r) => Object.keys(r)));
for (const r of rows) {
expect(new Set(Object.keys(r))).toEqual(union);
}
});
test('published_side is gone from the source entirely', () => {
const src = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
expect(src).not.toMatch(/published_side/);
});
});
describe('RETENTION FAILURE IS NOT SILENT', () => {
test('a PostgREST 400 is reported, never swallowed as success', async () => {
const failing = {
from: () => ({
upsert: async () => ({
error: { message: "Could not find the 'published_side' column of 'model_snapshots'", code: 'PGRST204' },
}),
}),
};
const out = await retention.persist(finalOutboundRows(), { getClient: () => failing });
expect(out.written).toBe(0);
expect(out.error).toMatch(/published_side/);
// Never reported as a success.
expect(out.skipped).toBe(false);
});
test('the pipeline emits a high-severity structured event on that failure', () => {
// MECHANISM CHANGED after 35da190: the alert condition was
// `r.error || (!r.skipped && r.attempted > 0 && r.written === 0)`, which
// could not distinguish a PARTIAL cohort from a complete one. It is now
// driven by the terminal status classified from the exact persist result,
// so FAILED_PARTIAL alerts as loudly as FAILED_ZERO_WRITE.
const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8');
const i = src.indexOf('if (retention.isRetentionFailure(terminal.status))');
expect(i).toBeGreaterThan(-1);
const block = src.slice(i, src.indexOf('} catch (e) {', i));
for (const field of ['stage=model_snapshots', 'snapshot_id=', 'code_sha=', 'at=', 'error=']) {
expect(block).toContain(field);
}
expect(block).toMatch(/priority: 'high'/);
expect(block).toMatch(/product is unaffected/);
});
test('a skipped write (no database configured) is NOT alerted', async () => {
const out = await retention.persist(finalOutboundRows(), { getClient: () => null });
expect(out.skipped).toBe(true);
expect(out.error).toBeNull();
// The skip exemption now lives in the classifier, not in an inline
// `!r.skipped` test at the call site.
expect(retention.classifyPersist(out)).toBe(retention.TERMINAL.SKIPPED_NO_DATABASE);
expect(retention.isRetentionFailure(retention.classifyPersist(out))).toBe(false);
});
test('zero rows written with candidates present also alerts', () => {
const st = retention.classifyPersist({ attempted: 600, written: 0, skipped: false, error: null });
expect(st).toBe(retention.TERMINAL.FAILED_ZERO_WRITE);
expect(retention.isRetentionFailure(st)).toBe(true);
});
});
describe('CYCLE PERSISTENCE MECHANISM', () => {
test('a cycle is written in chunks, and a failed chunk aborts the rest', async () => {
// Documented for the dark-cycle gate: persistence is CHUNKED (250), so a
// partial cycle is possible in principle — the loop breaks on the first
// error rather than continuing. There is no terminal completion marker.
const src = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8');
const body = src.slice(src.indexOf('async function persist('));
expect(body).toMatch(/const CHUNK = 250/);
expect(body).toMatch(/if \(error\) \{ out\.error = error\.message; break; \}/);
// `written` is the exact count of rows in successfully committed chunks, so
// written === attempted is the completion signal available today.
expect(body).toMatch(/out\.written \+= chunk\.length/);
});
test('written vs attempted distinguishes a complete cycle from a partial one', async () => {
const spy = spyClient();
const rows = finalOutboundRows();
const out = await retention.persist(rows, { getClient: () => spy.client });
expect(out.attempted).toBe(rows.length);
expect(out.written).toBe(rows.length);
});
});