'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([]); } const routeHits = walk(path.join(ROOT, 'src/routes')) .filter((f) => /readLineage|readAncestry|lineageCoverage/.test(fs.readFileSync(f, 'utf8'))) .map((f) => path.basename(f)); expect(routeHits).toEqual(['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}`); } } expect(hits).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); }); });