9dda9df132
The ancestry contract existed and nothing rendered it. This turns it into a product surface, and stops there. WHERE IT GOES. `LedgerCard` is the terminal surface — there is no Ledger detail view — so the history expands in place inside the card, matching the board's existing "ALL N READS" affordance rather than adding a page, a modal or a navigation category. It loads on first open, not on render. WHAT IT IS CALLED. "Read History". Lineage stays engineering vocabulary; a test asserts no rendered string contains lineage, natural key, ordinal, digest, graph, origin or recapture. ORIGIN reads "First published", REVISION reads "Updated", and the persisted `change_type` supplies "The price moved" / "The read changed" / "The read and the price changed". `change_type` is stored and trustworthy, so naming it is reporting; no field-level diff is persisted, so none is invented. THE SEPARATION, WHICH IS THE LOAD-BEARING PART. The grade strike means the LETTER changed. A history entry means the published CLAIM changed — often the price, sometimes the read, frequently with no letter change at all. The history uses no strike-through, shares no styling, and a tooth fails if it ever does. The ledger card's own strike is untouched. RECAPTURES ARE SUMMARISED, NEVER DESTROYED. A republishing board can produce hundreds of "unchanged" entries that bury the two that matter, so the UI collapses them to a count. The API still returns every one. WHAT EACH ENTRY SHOWS came from the acceptance run: without the published grade the history can say a Read changed but never what it changed to, which answers none of the questions someone opens a history to ask. `published_grade` / `published_p_win` / `published_line` are read off the SAME retained row the lineage action sits on — the authoritative record of the published claim, with lineage only the pointer to it. A tooth fails if they are ever synthesised from lineage metadata. TWO DEFECTS THE PRODUCTION ACCEPTANCE FOUND, NEITHER OF WHICH A TEST HAD. `ledger_entries.id` is a UUID and the route parsed it with Number.parseInt, so every real row would have 400'd. The unauthenticated probe that "proved the route was live" returns 401 from requireAuth before the handler runs, so it could never have seen this. And `chase burns / hits_allowed / under / 4.5` has SIX published captures and four lineage actions — two were published while the writer was off. The response said `chronology_complete: true` while showing four of six. Completeness now counts published-but-never-recorded states as well as attempted-and-failed ones, kept as separate numbers because the causes differ and the copy says which. Suite 399/5,544/0 · tsc 0 · web build 0 · teeth 15/15. Lint is not runnable in this repository (`next lint` removed in Next 16, no eslint.config.*) — pre-existing, untouched here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
529 lines
25 KiB
JavaScript
529 lines
25 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* READ LINEAGE — append-only chronology of published claims.
|
|
*
|
|
* Tests are written against SEMANTICS, not field spellings, because the design
|
|
* deliberately reused what the repository already had: `model_snapshots` rows
|
|
* are the immutable revisions and their own `id` is the revision identity.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const L = require('../../src/services/read/readLineage');
|
|
const retention = require('../../src/services/retentionService');
|
|
|
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
|
|
// `published: true` is REQUIRED as of the publication-truth work. A capture is
|
|
// not a publication: gradeSlateService fires its retention hook with BOTH sides,
|
|
// graded AND refused, before any filtering, and only the higher-confidence
|
|
// graded side becomes the served Read. Measured on mlb 2026-08-26, 8,443 of
|
|
// 13,012 captured rows (64.9%) describe a state no user was ever shown.
|
|
//
|
|
// These tests all describe a PUBLISHED claim, so the fixture carries the marker.
|
|
// Coverage of the unmarked case lives in publicationLineage.test.js.
|
|
const CLAIM = {
|
|
sport: 'mlb', game_date: '2026-08-27', player_key: 'aaron judge',
|
|
stat: 'hits', side: 'over', line: 0.5,
|
|
game_id: 'mlb:2026-08-27:BostonRedSox@NewYorkYankees',
|
|
captured_at: '2026-08-27T14:00:00Z',
|
|
grade: 'B', p_win: 0.61, confidence: 61, projection: 1.2, takeable: true,
|
|
published: true,
|
|
};
|
|
|
|
/** A persisted row, as the dual-write would have left it. */
|
|
const persisted = (id, claim, res) => ({
|
|
id,
|
|
read_id: res.read_id,
|
|
read_natural_key: res.read_natural_key,
|
|
game_id: claim.game_id,
|
|
claim_digest: res.claim_digest,
|
|
revision_ordinal: res.revision_ordinal,
|
|
lineage_action: res.action,
|
|
supersedes_id: res.supersedes_id ?? null,
|
|
captured_at: claim.captured_at,
|
|
});
|
|
|
|
const mint = (v) => () => v;
|
|
|
|
describe('ORIGINAL PUBLICATION', () => {
|
|
test('a new Read creates one logical Read and one immutable initial revision', () => {
|
|
const r = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.action).toBe(L.LINEAGE_ACTION.ORIGIN);
|
|
expect(r.read_id).toBe('R1');
|
|
expect(r.revision_ordinal).toBe(0);
|
|
expect(r.supersedes_id).toBeNull();
|
|
expect(r.claim_digest).toEqual(expect.any(String));
|
|
});
|
|
});
|
|
|
|
describe('SECOND PUBLICATION', () => {
|
|
test('a materially different claim creates a second revision pointing at the first', () => {
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const changed = { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' };
|
|
const r2 = L.resolveLineage({ candidate: changed, existing: [row1] });
|
|
|
|
expect(r2.action).toBe(L.LINEAGE_ACTION.REVISION);
|
|
expect(r2.read_id).toBe('R1');
|
|
expect(r2.revision_ordinal).toBe(1);
|
|
expect(r2.supersedes_id).toBe(101);
|
|
expect(r2.claim_digest).not.toBe(r1.claim_digest);
|
|
});
|
|
});
|
|
|
|
describe('NO MUTATION', () => {
|
|
test('creating revision 2 leaves revision 1 byte-for-byte unchanged', () => {
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const before = JSON.stringify(row1);
|
|
L.resolveLineage({
|
|
candidate: { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' },
|
|
existing: [row1],
|
|
});
|
|
expect(JSON.stringify(row1)).toBe(before);
|
|
expect(Object.isFrozen(r1)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('THIRD REVISION', () => {
|
|
test('a deterministic chain R1 -> R2 -> R3 forms and walks', () => {
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const c2 = { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' };
|
|
const r2 = L.resolveLineage({ candidate: c2, existing: [row1] });
|
|
const row2 = persisted(102, c2, r2);
|
|
const c3 = { ...CLAIM, grade: 'D', p_win: 0.49, captured_at: '2026-08-27T22:00:00Z' };
|
|
const r3 = L.resolveLineage({ candidate: c3, existing: [row1, row2] });
|
|
const row3 = persisted(103, c3, r3);
|
|
|
|
expect(r3.revision_ordinal).toBe(2);
|
|
expect(r3.supersedes_id).toBe(102);
|
|
|
|
const chron = L.chronology([row3, row1, row2]);
|
|
expect(chron.ok).toBe(true);
|
|
expect(chron.revisions.map((r) => r.revision_ordinal)).toEqual([0, 1, 2]);
|
|
expect(chron.revision_count).toBe(2);
|
|
expect(chron.original.id).toBe(101);
|
|
expect(chron.current.id).toBe(103);
|
|
});
|
|
|
|
test('a broken supersession chain is reported, not silently reordered', () => {
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const orphan = { ...row1, id: 103, revision_ordinal: 2, lineage_action: 'REVISION', supersedes_id: 999 };
|
|
expect(L.chronology([row1, orphan]).ok).toBe(false);
|
|
expect(L.chronology([row1, orphan]).reason).toMatch(/broken supersession/);
|
|
});
|
|
});
|
|
|
|
describe('EXACT READ IDENTITY', () => {
|
|
test('two genuinely different games do not share one logical Read', () => {
|
|
// Real prod shape: `jac caglianone` on 2026-08-22 under two different
|
|
// matchups. Same player, stat, line and side; different events.
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const otherGame = { ...CLAIM, game_id: 'mlb:2026-08-27:MinnesotaTwins@SanDiegoPadres' };
|
|
const r2 = L.resolveLineage({ candidate: otherGame, existing: [row1], mintReadId: mint('R2') });
|
|
|
|
expect(r2.action).toBe(L.LINEAGE_ACTION.ORIGIN);
|
|
expect(r2.read_id).toBe('R2');
|
|
expect(r2.supersedes_id).toBeNull();
|
|
});
|
|
|
|
test('an incomplete identity refuses rather than keying on a partial tuple', () => {
|
|
for (const missing of ['sport', 'game_date', 'player_key', 'stat', 'side']) {
|
|
const bad = { ...CLAIM, [missing]: null };
|
|
expect(L.resolveLineage({ candidate: bad, existing: [] }).refused).toBe('incomplete_read_identity');
|
|
}
|
|
expect(L.readNaturalKey({ ...CLAIM, line: null })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('UNSTABLE GAME-ID SPELLING', () => {
|
|
test('two spellings of one game stay ONE logical Read', () => {
|
|
// Measured: 1,328 of 30,746 identity groups carry more than one spelling.
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
// A REAL spelling pair from the data: measured over 52 distinct spelling
|
|
// pairs in one week, 46 are prefix-compatible like this one.
|
|
const abbreviated = {
|
|
...CLAIM, game_id: 'mlb:2026-08-27:BostonRed@NewYork',
|
|
grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z',
|
|
};
|
|
const r2 = L.resolveLineage({ candidate: abbreviated, existing: [row1] });
|
|
expect(r2.read_id).toBe('R1');
|
|
expect(r2.action).toBe(L.LINEAGE_ACTION.REVISION);
|
|
});
|
|
|
|
test('the prefix rule matches abbreviations and refuses unrelated pairs', () => {
|
|
expect(L.sameEvent('nba:2026-10-20:OKC@SAS', 'nba:2026-10-20:OKCThunder@SASpurs')).toBe(true);
|
|
expect(L.sameEvent('mlb:d:DetroitTigers@KansasCityRoyals', 'mlb:d:MinnesotaTwins@SanDiegoPadres')).toBe(false);
|
|
// An unparseable id is never assumed to match — a guess would merge events.
|
|
expect(L.sameEvent('garbage', 'mlb:d:A@B')).toBe(false);
|
|
expect(L.eventFingerprint('garbage')).toBeNull();
|
|
});
|
|
|
|
test('KNOWN LIMIT: a non-prefix initialism is NOT reconciled, and fails closed', () => {
|
|
// 'NYY' is not a prefix of 'newyorkyankees', so the rule cannot match them
|
|
// and the two spellings become two logical Reads. That is a fracture, not a
|
|
// merge — the safe direction, since merging two events would be
|
|
// unrecoverable while a fracture is visible and repairable.
|
|
//
|
|
// MEASURED: of 52 distinct spelling pairs across one week of real data, 46
|
|
// are prefix-compatible and the 6 that are not are genuinely different
|
|
// games. No initialism-style pair for one game occurred. Recorded as a
|
|
// limit rather than fixed with an invented team-name mapping table.
|
|
expect(L.sameEvent('mlb:d:BostonRedSox@NewYorkYankees', 'mlb:d:BOS@NYY')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('RETRY / IDEMPOTENCY', () => {
|
|
test('retrying the same publication does not create a duplicate revision', () => {
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const retry = L.resolveLineage({ candidate: { ...CLAIM }, existing: [row1] });
|
|
expect(retry.action).toBe(L.LINEAGE_ACTION.RECAPTURE);
|
|
expect(retry.revision_ordinal).toBe(0);
|
|
expect(retry.supersedes_id).toBeNull();
|
|
expect(retry.recaptures_id).toBe(101);
|
|
});
|
|
|
|
test('a later cycle observing the SAME claim is a recapture, not a revision', () => {
|
|
// On the measured date only 183 of 5,376 props changed while 4,286 had
|
|
// multiple rows. Counting every capture as a revision would report ~13,000
|
|
// published claims where ~183 occurred.
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const later = { ...CLAIM, captured_at: '2026-08-27T22:00:00Z' };
|
|
expect(L.resolveLineage({ candidate: later, existing: [row1] }).action)
|
|
.toBe(L.LINEAGE_ACTION.RECAPTURE);
|
|
});
|
|
|
|
test('the digest ignores non-claim fields and reacts to every claim field', () => {
|
|
const base = L.claimDigest(CLAIM);
|
|
// captured_at is not part of the claim.
|
|
expect(L.claimDigest({ ...CLAIM, captured_at: 'zzz' })).toBe(base);
|
|
// 1.5 and "1.5" are one claim, not two.
|
|
expect(L.claimDigest({ ...CLAIM, line: 0.5 })).toBe(L.claimDigest({ ...CLAIM, line: 0.5 }));
|
|
for (const f of ['grade', 'p_win', 'line', 'side', 'book', 'locked_odds',
|
|
'over_odds', 'under_odds', 'confidence', 'projection']) {
|
|
const changed = typeof CLAIM[f] === 'number' ? CLAIM[f] + 1 : `${CLAIM[f] || ''}X`;
|
|
expect(L.claimDigest({ ...CLAIM, [f]: changed })).not.toBe(base);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('CONCURRENCY', () => {
|
|
test('two writers that cannot see each other both target the SAME parent', () => {
|
|
// This is the race, stated exactly. Both resolvers read the same `existing`
|
|
// before either wrote, so both legitimately produce supersedes_id = 101.
|
|
// No in-memory check can catch this — neither writer can see the other.
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const stale = [row1];
|
|
|
|
const a = L.resolveLineage({ candidate: { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' }, existing: stale });
|
|
const b = L.resolveLineage({ candidate: { ...CLAIM, grade: 'D', p_win: 0.49, captured_at: '2026-08-27T19:00:01Z' }, existing: stale });
|
|
|
|
expect(a.supersedes_id).toBe(101);
|
|
expect(b.supersedes_id).toBe(101);
|
|
expect(a.revision_ordinal).toBe(b.revision_ordinal);
|
|
// Which is why the UNIQUE index on supersedes_id exists — it is the only
|
|
// thing that can stop the second insert. Proven against a real database in
|
|
// the migration verification, not here.
|
|
const mig = fs.readFileSync(path.join(ROOT, 'supabase/migrations/045_read_lineage.sql'), 'utf8');
|
|
expect(mig).toMatch(/CREATE UNIQUE INDEX[\s\S]*?supersedes_id/);
|
|
});
|
|
|
|
test('a resolver that CAN see the sibling appends after it — that is not a fork', () => {
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const a = { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' };
|
|
const ra = L.resolveLineage({ candidate: a, existing: [row1] });
|
|
const rowA = persisted(102, a, ra);
|
|
const rb = L.resolveLineage({ candidate: { ...CLAIM, grade: 'D', p_win: 0.49, captured_at: '2026-08-27T20:00:00Z' }, existing: [row1, rowA] });
|
|
expect(rb.ok).toBe(true);
|
|
expect(rb.supersedes_id).toBe(102);
|
|
expect(rb.revision_ordinal).toBe(2);
|
|
});
|
|
|
|
test('a chronology containing a fork is reported as broken', () => {
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const forkA = { ...row1, id: 102, revision_ordinal: 1, lineage_action: 'REVISION', supersedes_id: 101, claim_digest: 'aaa' };
|
|
const forkB = { ...row1, id: 103, revision_ordinal: 1, lineage_action: 'REVISION', supersedes_id: 101, claim_digest: 'bbb' };
|
|
expect(L.chronology([row1, forkA, forkB]).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('EXACT TIMESTAMP', () => {
|
|
test('a row without a capture clock refuses rather than being stamped with now()', () => {
|
|
const r = L.resolveLineage({ candidate: { ...CLAIM, captured_at: null }, existing: [] });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.refused).toBe('no_capture_clock');
|
|
});
|
|
|
|
test('the standing revision at an instant is answerable', () => {
|
|
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, CLAIM, r1);
|
|
const c2 = { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' };
|
|
const r2 = L.resolveLineage({ candidate: c2, existing: [row1] });
|
|
const row2 = persisted(102, c2, r2);
|
|
|
|
expect(L.revisionAt([row1, row2], '2026-08-27T15:00:00Z').grade || 'B').toBe('B');
|
|
expect(L.revisionAt([row1, row2], '2026-08-27T15:00:00Z').id).toBe(101);
|
|
expect(L.revisionAt([row1, row2], '2026-08-27T20:00:00Z').id).toBe(102);
|
|
expect(L.revisionAt([row1, row2], '2026-08-27T10:00:00Z')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('LEGACY UNKNOWN', () => {
|
|
test('rows with no chain position produce LEGACY_UNVERIFIED, never an invented order', () => {
|
|
const legacy = {
|
|
id: 55, read_id: null, read_natural_key: null,
|
|
game_id: CLAIM.game_id, claim_digest: null,
|
|
revision_ordinal: null, lineage_action: null, supersedes_id: null,
|
|
captured_at: '2026-08-01T14:00:00Z',
|
|
};
|
|
const r = L.resolveLineage({ candidate: CLAIM, existing: [legacy], mintReadId: mint('R9') });
|
|
expect(r.ok).toBe(true);
|
|
expect(r.lineage_state).toBe(L.LINEAGE_STATE.LEGACY_UNVERIFIED);
|
|
expect(r.action).toBe(L.LINEAGE_ACTION.ORIGIN);
|
|
expect(r.supersedes_id).toBeNull();
|
|
});
|
|
|
|
test('no backfill exists — nothing in the tree writes lineage onto historical rows', () => {
|
|
const files = ['src/services/read/readLineage.js', 'src/services/retentionService.js'];
|
|
for (const f of files) {
|
|
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
|
|
expect(src).not.toMatch(/backfill/i);
|
|
}
|
|
const mig = fs.readFileSync(path.join(ROOT, 'supabase/migrations/045_read_lineage.sql'), 'utf8');
|
|
expect(mig).not.toMatch(/UPDATE\s+\w+\s+SET/i);
|
|
expect(mig).not.toMatch(/\bDROP\b/i);
|
|
});
|
|
});
|
|
|
|
describe('DUAL-WRITE', () => {
|
|
const rows = () => ([
|
|
{ ...CLAIM },
|
|
{ ...CLAIM, side: 'under', grade: 'C', p_win: 0.39 },
|
|
]);
|
|
|
|
test('lineage keys are declared on EVERY row even when unresolved', async () => {
|
|
const r = rows();
|
|
await retention.attachLineage(r, { fetchExisting: async () => null });
|
|
for (const row of r) {
|
|
for (const k of retention.LINEAGE_KEYS) expect(k in row).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('a lineage failure never throws and leaves rows persistable', async () => {
|
|
const r = rows();
|
|
const out = await retention.attachLineage(r, {
|
|
fetchExisting: async () => { throw new Error('db exploded'); },
|
|
});
|
|
expect(out.error).toBe('db exploded');
|
|
for (const row of r) expect(row.read_id).toBeNull();
|
|
});
|
|
|
|
test('two rows of the SAME Read in one batch do not each mint an id', async () => {
|
|
// over and under are different Reads; two captures of one side are not.
|
|
const r = [{ ...CLAIM }, { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' }];
|
|
let n = 0;
|
|
const out = await retention.attachLineage(r, {
|
|
fetchExisting: async () => [],
|
|
mintReadId: () => `R${++n}`,
|
|
});
|
|
expect(n).toBe(1);
|
|
expect(r[0].read_id).toBe('R1');
|
|
expect(r[1].read_id).toBe('R1');
|
|
expect(out.origins).toBe(1);
|
|
expect(out.revisions).toBe(1);
|
|
});
|
|
|
|
test('counts distinguish origins, revisions and recaptures', async () => {
|
|
const r = [{ ...CLAIM }, { ...CLAIM, captured_at: '2026-08-27T19:00:00Z' }];
|
|
const out = await retention.attachLineage(r, { fetchExisting: async () => [], mintReadId: mint('R1') });
|
|
expect(out.origins).toBe(1);
|
|
expect(out.recaptures).toBe(1);
|
|
expect(out.revisions).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('SHADOW AUTHORITY', () => {
|
|
test('no production read path imports the lineage module', () => {
|
|
const walk = (dir, out = []) => {
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) walk(full, out);
|
|
else if (/\.(js|jsx|ts|tsx)$/.test(e.name)) out.push(full);
|
|
}
|
|
return out;
|
|
};
|
|
const allowed = new Set([
|
|
path.join(ROOT, 'src/services/read/readLineage.js'),
|
|
path.join(ROOT, 'src/services/retentionService.js'), // the dual-writer
|
|
// ADDITIVE, NON-AUTHORITATIVE READERS (lineage productization).
|
|
// The property this test protects is "no path that serves PRODUCT truth
|
|
// reads lineage" — not "nothing reads lineage", which was only ever true
|
|
// because nothing had been built yet. These two read it to MEASURE it and
|
|
// to expose ancestry on a protected internal route; neither is consumed
|
|
// by any product surface, which the assertions below now police directly.
|
|
path.join(ROOT, 'src/services/lineageCoverage.js'),
|
|
path.join(ROOT, 'src/services/read/readAncestry.js'),
|
|
]);
|
|
const files = [
|
|
...walk(path.join(ROOT, 'src')),
|
|
...(fs.existsSync(path.join(ROOT, 'web/src')) ? walk(path.join(ROOT, 'web/src')) : []),
|
|
].filter((f) => !allowed.has(f));
|
|
const offenders = files.filter((f) => {
|
|
const src = fs.readFileSync(f, 'utf8')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
return /(require\(|from\s+)['"`][^'"`]*readLineage['"`]/.test(src);
|
|
}).map((f) => path.relative(ROOT, f));
|
|
expect(offenders).toEqual([]);
|
|
|
|
// STRONGER THAN BEFORE AT THE LAYERS THAT MATTER.
|
|
// web/src must contain no reference at all, and among the routes exactly
|
|
// ONE — the protected internal router — may reach lineage. Previously no
|
|
// route could, so this keeps the boundary explicit rather than widening it.
|
|
const webDir = path.join(ROOT, 'web/src');
|
|
if (fs.existsSync(webDir)) {
|
|
const webHits = walk(webDir).filter((f) => /readLineage|readAncestry|lineageCoverage/
|
|
.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(ROOT, f));
|
|
expect(webHits).toEqual([]);
|
|
}
|
|
// Exactly TWO routers may reach lineage: the protected internal router and
|
|
// the additive ancestry contract. The ledger and profile routers — which
|
|
// serve the grade-shift badge — must never appear here, which is what makes
|
|
// this list a boundary rather than a rubber stamp.
|
|
const routeHits = walk(path.join(ROOT, 'src/routes'))
|
|
.filter((f) => /readLineage|readAncestry|lineageCoverage/.test(fs.readFileSync(f, 'utf8')))
|
|
.map((f) => path.basename(f)).sort();
|
|
expect(routeHits).toEqual(['ancestry.js', 'internal.js']);
|
|
});
|
|
|
|
test('no route or web file selects a lineage column', () => {
|
|
const cols = ['read_natural_key', 'claim_digest', 'revision_ordinal', 'lineage_action', 'supersedes_id'];
|
|
const roots = [path.join(ROOT, 'src/routes')];
|
|
if (fs.existsSync(path.join(ROOT, 'web/src'))) roots.push(path.join(ROOT, 'web/src'));
|
|
const walk = (dir, out = []) => {
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.name.startsWith('.')) continue;
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) walk(full, out); else out.push(full);
|
|
}
|
|
return out;
|
|
};
|
|
const hits = [];
|
|
for (const r of roots) {
|
|
for (const f of walk(r)) {
|
|
if (!/\.(js|jsx|ts|tsx)$/.test(f)) continue;
|
|
const src = fs.readFileSync(f, 'utf8');
|
|
for (const c of cols) if (src.includes(c)) hits.push(`${path.relative(ROOT, f)} -> ${c}`);
|
|
}
|
|
}
|
|
// ONE product-side consumer of the ancestry CONTRACT is permitted, by name.
|
|
// It reads two fields off an API RESPONSE — never from the store — which is
|
|
// the whole point of the additive contract, and the assertion below keeps
|
|
// that distinction honest: if this file ever learns to query, it fails.
|
|
const ANCESTRY_CONSUMER = 'web/src/lib/readHistory.js';
|
|
const consumer = fs.readFileSync(path.join(ROOT, ANCESTRY_CONSUMER), 'utf8');
|
|
for (const q of ['supabase', 'from(', 'select(', 'createClient']) {
|
|
expect(consumer).not.toContain(q);
|
|
}
|
|
expect(hits.filter((h) => !h.startsWith(ANCESTRY_CONSUMER))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('MODEL PRESERVATION', () => {
|
|
test('lineage never touches a served field', () => {
|
|
const { SERVED_FIELDS } = require('../../src/services/evaluator/gradeFreeze');
|
|
for (const k of retention.LINEAGE_KEYS) expect(SERVED_FIELDS).not.toContain(k);
|
|
});
|
|
|
|
test('attaching lineage leaves every claim field byte-identical', async () => {
|
|
const row = { ...CLAIM };
|
|
const before = JSON.stringify(row);
|
|
await retention.attachLineage([row], { fetchExisting: async () => [], mintReadId: mint('R1') });
|
|
const after = { ...row };
|
|
for (const k of retention.LINEAGE_KEYS) delete after[k];
|
|
expect(JSON.stringify(after)).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe('USER LOCK SEPARATION', () => {
|
|
test('there is no user Lock-Read concept to separate from — recorded, not assumed', () => {
|
|
// Traced: "locked" in this codebase means the SYSTEM's locked line/grade at
|
|
// snapshot time (gradedAt.line / locked_odds), never a user action. No
|
|
// lockRead / lock_read symbol exists anywhere in src or web/src.
|
|
const walk = (dir, out = []) => {
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) walk(full, out);
|
|
else if (/\.(js|jsx|ts|tsx)$/.test(e.name)) out.push(full);
|
|
}
|
|
return out;
|
|
};
|
|
const files = [
|
|
...walk(path.join(ROOT, 'src')),
|
|
...(fs.existsSync(path.join(ROOT, 'web/src')) ? walk(path.join(ROOT, 'web/src')) : []),
|
|
];
|
|
const hits = files.filter((f) => /lockRead|lock_read/i.test(fs.readFileSync(f, 'utf8')));
|
|
expect(hits).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('MEASURED SHAPE (locked from a real read-only replay)', () => {
|
|
test('the digest sees price in BOTH stores, which name it differently', () => {
|
|
// `ledger_entries` carries locked_odds; `model_snapshots` carries
|
|
// over_odds/under_odds. A first draft listed only locked_odds, which made
|
|
// the digest blind to price on exactly the rows it digests — a price move
|
|
// would have read as an unchanged claim. Caught by the replay failing on a
|
|
// column that does not exist.
|
|
const ms = { line: 0.5, side: 'over', book: 'dk', over_odds: 120, under_odds: -145, grade: 'B' };
|
|
expect(L.claimDigest(ms)).not.toBe(L.claimDigest({ ...ms, over_odds: 180 }));
|
|
const led = { line: 0.5, side: 'over', book: 'dk', locked_odds: 120, grade: 'B' };
|
|
expect(L.claimDigest(led)).not.toBe(L.claimDigest({ ...led, locked_odds: 180 }));
|
|
for (const f of ['locked_odds', 'over_odds', 'under_odds']) {
|
|
expect(L.CLAIM_MARKET_FIELDS).toContain(f);
|
|
}
|
|
});
|
|
|
|
test('ONE natural key can legitimately hold TWO Reads', () => {
|
|
// Measured: 18 of 30,746 identity groups carry genuinely different team
|
|
// pairs. Grouping a chronology by natural key instead of read_id therefore
|
|
// reports those as broken chains when they are the resolver working.
|
|
const a = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('RA') });
|
|
const rowA = persisted(101, CLAIM, a);
|
|
const other = { ...CLAIM, game_id: 'mlb:2026-08-27:MinnesotaTwins@SanDiegoPadres' };
|
|
const b = L.resolveLineage({ candidate: other, existing: [rowA], mintReadId: mint('RB') });
|
|
const rowB = persisted(102, other, b);
|
|
|
|
expect(L.readNaturalKey(CLAIM)).toBe(L.readNaturalKey(other));
|
|
expect(a.read_id).not.toBe(b.read_id);
|
|
// Mixed bucket is rejected...
|
|
expect(L.chronology([rowA, rowB]).ok).toBe(false);
|
|
// ...and each Read on its own is a valid chain.
|
|
expect(L.chronology([rowA]).ok).toBe(true);
|
|
expect(L.chronology([rowB]).ok).toBe(true);
|
|
});
|
|
|
|
test('a claim that changes only PRICE is still a new published claim', () => {
|
|
// 4,206 of 4,507 replayed revisions moved price without moving the grade.
|
|
// The user was shown a different number, so it is a different claim.
|
|
const r1 = L.resolveLineage({ candidate: { ...CLAIM, over_odds: 120 }, existing: [], mintReadId: mint('R1') });
|
|
const row1 = persisted(101, { ...CLAIM, over_odds: 120 }, r1);
|
|
const repriced = { ...CLAIM, over_odds: 180, captured_at: '2026-08-27T19:00:00Z' };
|
|
const r2 = L.resolveLineage({ candidate: repriced, existing: [row1] });
|
|
expect(r2.action).toBe(L.LINEAGE_ACTION.REVISION);
|
|
expect(r2.revision_ordinal).toBe(1);
|
|
});
|
|
});
|