diff --git a/scripts/teeth-read-history-ux.js b/scripts/teeth-read-history-ux.js
new file mode 100644
index 0000000..076c498
--- /dev/null
+++ b/scripts/teeth-read-history-ux.js
@@ -0,0 +1,110 @@
+#!/usr/bin/env node
+/* READ-HISTORY UX TEETH. Inject, prove the suite CATCHES it, restore exactly.
+ Every tooth asserts its injection landed first — a green teeth run means the
+ test is missing, not that the risk is absent. */
+const fs = require('fs'); const path = require('path'); const cp = require('child_process');
+const F = (p) => path.join(__dirname, p);
+const U = 'tests/unit/readHistoryUx.test.js';
+const C = 'tests/unit/lineageCloseout.test.js';
+const P = 'tests/unit/lineageProductization.test.js';
+
+const TEETH = [
+ { n: 1, name: 'the history reuses the grade-revision strike',
+ file: 'web/src/components/vyndr/ReadHistory.tsx',
+ from: "fontSize: 11, fontWeight: 700, color: TONE[e.tone]",
+ to: "fontSize: 11, fontWeight: 700, textDecoration: 'line-through', color: TONE[e.tone]",
+ suite: U },
+ { n: 2, name: 'legacy renders as a complete empty history',
+ file: 'web/src/lib/readHistory.js',
+ from: " if (state !== 'LINEAGE_AVAILABLE') {",
+ to: " if (state === 'NEVER') {",
+ suite: U },
+ { n: 3, name: 'LINEAGE_UNAVAILABLE silently reads as legacy',
+ file: 'web/src/lib/readHistory.js',
+ from: " LINEAGE_UNAVAILABLE: {\n headline: 'History unavailable',",
+ to: " LINEAGE_UNAVAILABLE: {\n headline: 'History not recorded',",
+ suite: U },
+ { n: 4, name: 'an incomplete chronology claims to be complete',
+ file: 'web/src/lib/readHistory.js',
+ from: " const incomplete = a.chronology_complete === false || unrecorded > 0 || before > 0;",
+ to: " const incomplete = false;",
+ suite: U },
+ { n: 5, name: 'invalid lineage elects a head anyway',
+ file: 'src/services/read/readAncestry.js',
+ from: " state: ANCESTRY_STATE.INVALID_LINEAGE,\n reason: chron.reason,",
+ to: " state: ANCESTRY_STATE.LINEAGE_AVAILABLE,\n reason: chron.reason,",
+ suite: `${C} ${P}` },
+ { n: 6, name: 'the public route is keyed on the raw natural key',
+ file: 'src/routes/ancestry.js',
+ from: "const LEDGER_COLUMNS = 'id, sport",
+ to: "const NATURAL = 'read_natural_key';\nconst LEDGER_COLUMNS = 'id, sport",
+ suite: `${U} ${C}` },
+ { n: 7, name: 'ancestry succeeds unauthenticated',
+ file: 'src/routes/ancestry.js',
+ from: "router.get('/ledger/:id', requireAuth, async (req, res) => {",
+ to: "router.get('/ledger/:id', async (req, res) => {",
+ suite: `${U} ${C}` },
+ { n: 8, name: 'the UI bypasses the API client with ad hoc fetch',
+ file: 'web/src/components/vyndr/ReadHistory.tsx',
+ from: " const { ok, status, data } = await getReadAncestry(ledgerId, token);",
+ to: " const r = await fetch('/api/ancestry/ledger/' + ledgerId); const ok = r.ok, status = r.status, data = await r.json();",
+ suite: U },
+ { n: 9, name: 'recaptures are destroyed instead of summarised',
+ file: 'web/src/lib/readHistory.js',
+ from: " if (list.length === 0) return null;\n return {\n kind: 'recapture-summary',",
+ to: " if (list.length >= 0) return null;\n return {\n kind: 'recapture-summary',",
+ suite: U },
+ { n: 10, name: 'published values are synthesised from lineage metadata',
+ file: 'src/services/read/readAncestry.js',
+ from: " published_grade: row.grade ?? null,",
+ to: " published_grade: row.claim_digest ? row.claim_digest.slice(0, 1) : null,",
+ suite: U },
+ { n: 11, name: 'the existing grade strike is removed from the ledger card',
+ file: 'web/src/app/ledger/page.tsx',
+ from: "textDecoration: 'line-through'",
+ to: "textDecoration: 'none'",
+ suite: U },
+ { n: 12, name: 'persistent shadow becomes lease-dependent',
+ file: 'src/services/lineageWriteMode.js',
+ from: " return Object.prototype.hasOwnProperty.call(evaluate(now).modes, sp);",
+ to: " return canary.isEnabled(sp, now);",
+ suite: P },
+ { n: 13, name: 'the coverage monitor denominator reads lineage_action',
+ file: 'src/services/lineageCoverage.js',
+ from: " if (!r || r.published !== true) continue;",
+ to: " if (!r || r.published !== true || r.lineage_action == null) continue;",
+ suite: `${C} ${P}` },
+ { n: 14, name: 'participant identity convergence reverted',
+ file: 'src/services/model/participantIdentity.js',
+ from: " const canonical = p.mlb_person_id != null ? p.canonical_player_name : null;",
+ to: " const canonical = null;",
+ suite: `${P} tests/unit/participantIdentityConvergence.test.js` },
+ { n: 15, name: 'the retention collision gate is weakened',
+ file: 'src/services/retentionService.js',
+ from: " if (seen.has(id)) collisions += 1;",
+ to: " if (false) collisions += 1;",
+ suite: `${P} tests/unit/canonicalParticipant.test.js` },
+];
+
+let caught = 0; const missed = [];
+for (const t of TEETH) {
+ const p = F(t.file);
+ const orig = fs.readFileSync(p, 'utf8');
+ if (!orig.includes(t.from)) { missed.push(`${t.n} ANCHOR NOT FOUND: ${t.name}`); continue; }
+ const broken = orig.replace(t.from, t.to);
+ if (broken === orig) { missed.push(`${t.n} INJECTION NO-OP: ${t.name}`); continue; }
+ fs.writeFileSync(p, broken);
+ const present = fs.readFileSync(p, 'utf8').includes(t.to);
+ let failed = false;
+ try {
+ cp.execSync(`npx jest ${t.suite} --testPathIgnorePatterns "/node_modules/" --silent --forceExit`,
+ { cwd: __dirname, stdio: 'pipe' });
+ } catch { failed = true; }
+ fs.writeFileSync(p, orig);
+ if (fs.readFileSync(p, 'utf8') !== orig) { console.error('RESTORE FAILED', t.file); process.exit(2); }
+ if (!present) { missed.push(`${t.n} INJECTION NOT PRESENT: ${t.name}`); continue; }
+ if (failed) { caught += 1; console.log(` tooth ${String(t.n).padStart(2)} CAUGHT ${t.name}`); }
+ else { missed.push(`${t.n} NOT CAUGHT: ${t.name}`); console.log(` tooth ${String(t.n).padStart(2)} MISSED ${t.name}`); }
+}
+console.log(`\n ${caught}/${TEETH.length} teeth landed`);
+if (missed.length) { console.log(' PROBLEMS:'); missed.forEach((m) => console.log(' ', m)); process.exit(1); }
diff --git a/src/services/read/readAncestry.js b/src/services/read/readAncestry.js
index 81867fc..5f37bfa 100644
--- a/src/services/read/readAncestry.js
+++ b/src/services/read/readAncestry.js
@@ -73,6 +73,15 @@ function project(row) {
snapshot_id: row.snapshot_id ?? null,
model_version: row.model_version ?? null,
code_sha: row.code_sha ?? null,
+ // WHAT WAS PUBLISHED AT THIS POINT, read off the SAME retained row the
+ // lineage action sits on. This is the authoritative record of the published
+ // claim — lineage is only the pointer to it, and nothing here is
+ // reconstructed from lineage metadata. Without it the history can say a
+ // Read changed but never what it changed to, which answers none of the
+ // questions a person actually opens a history to ask.
+ published_grade: row.grade ?? null,
+ published_p_win: row.p_win === null || row.p_win === undefined ? null : Number(row.p_win),
+ published_line: row.line === null || row.line === undefined ? null : Number(row.line),
});
}
@@ -147,11 +156,29 @@ function classifyAncestry(rows) {
// over; it is the same failure this surface exists to make visible, just at
// a finer grain than a whole Read.
const unrecorded = attempted.filter((r) => !isValidAction(r)).length;
+ // A SECOND WAY THE HISTORY CAN HAVE A HOLE, found by the production
+ // acceptance run and not by any test.
+ //
+ // `chase burns / hits_allowed / under / 4.5` has SIX published captures and
+ // four lineage actions: two were published while the writer was switched off,
+ // so they carry no publication stamp at all. They are not failed attempts —
+ // nothing tried to record them — but they ARE published states the reader
+ // cannot see, and answering `complete` while showing four of six is an
+ // overstatement a user would reasonably be misled by.
+ //
+ // Counted separately from `unrecorded_publications` because the causes differ
+ // (attempted-and-failed vs never-attempted) and collapsing them would hide
+ // which one happened. Completeness now requires BOTH to be zero.
+ const publishedWithoutLineage = list.filter(
+ (r) => r.published === true && !isValidAction(r)
+ && (r.publication_id === null || r.publication_id === undefined),
+ ).length;
return Object.freeze({
state: ANCESTRY_STATE.LINEAGE_AVAILABLE,
reason: null,
- chronology_complete: unrecorded === 0,
+ chronology_complete: unrecorded === 0 && publishedWithoutLineage === 0,
unrecorded_publications: unrecorded,
+ publications_before_recording: publishedWithoutLineage,
read_id: chron.read_id,
read_natural_key: chain[0].read_natural_key,
revision_count: chron.revision_count,
@@ -170,6 +197,8 @@ const ANCESTRY_COLUMNS = [
'id', 'snapshot_id', 'sport', 'game_date', 'player_key', 'stat', 'side', 'line',
'canonical_event_id', 'event_occurrence', 'captured_at', 'published', 'published_at',
'publication_id', 'model_version', 'code_sha',
+ // The published claim itself, from the authoritative retained row.
+ 'grade', 'p_win',
...VALID_ACTION_FIELDS, 'supersedes_id', 'recaptures_id', 'change_type',
].join(', ');
diff --git a/tests/unit/lineageCloseout.test.js b/tests/unit/lineageCloseout.test.js
index 557390b..704937b 100644
--- a/tests/unit/lineageCloseout.test.js
+++ b/tests/unit/lineageCloseout.test.js
@@ -309,6 +309,37 @@ describe('ancestry response states, end to end', () => {
expect(ancestry.classifyAncestry([]).state).toBe(A.NOT_FOUND);
});
+ test('a published capture the writer never recorded also breaks completeness', () => {
+ // The real `chase burns / hits_allowed / under / 4.5` shape: published
+ // captures from a window when the writer was off. Not a failed attempt —
+ // nothing tried — but still a published state the reader cannot see.
+ const out = ancestry.classifyAncestry([
+ act({ id: 1 }),
+ { id: 2, published: true, publication_id: null },
+ ]);
+ expect(out.state).toBe(A.LINEAGE_AVAILABLE);
+ expect(out.chronology_complete).toBe(false);
+ expect(out.publications_before_recording).toBe(1);
+ expect(out.unrecorded_publications).toBe(0); // distinct cause, distinct count
+ });
+
+ test('the two kinds of hole are counted separately', () => {
+ const out = ancestry.classifyAncestry([
+ act({ id: 1 }),
+ { id: 2, published: true, publication_id: 'p2' }, // attempted, failed
+ { id: 3, published: true, publication_id: null }, // never attempted
+ ]);
+ expect(out.unrecorded_publications).toBe(1);
+ expect(out.publications_before_recording).toBe(1);
+ expect(out.chronology_complete).toBe(false);
+ });
+
+ test('a genuinely whole history still reports complete', () => {
+ const out = ancestry.classifyAncestry([act({ id: 1, published: true })]);
+ expect(out.chronology_complete).toBe(true);
+ expect(out.publications_before_recording).toBe(0);
+ });
+
test('other sports are unaffected — nothing here is sport-specific product logic', () => {
const out = ancestry.classifyAncestry([act({ id: 1 })]);
expect(out.state).toBe(A.LINEAGE_AVAILABLE);
diff --git a/tests/unit/publicationLineage.test.js b/tests/unit/publicationLineage.test.js
index 47bf20f..51c849d 100644
--- a/tests/unit/publicationLineage.test.js
+++ b/tests/unit/publicationLineage.test.js
@@ -367,7 +367,16 @@ describe('NON-AUTHORITY — lineage cannot serve the product', () => {
for (const c of cols) if (src.includes(c)) hits.push(`${path.relative(ROOT, f)} -> ${c}`);
}
}
- expect(hits).toEqual([]);
+ // 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([]);
});
test('the served envelope comes from the cache, not from model_snapshots', () => {
diff --git a/tests/unit/readHistoryUx.test.js b/tests/unit/readHistoryUx.test.js
new file mode 100644
index 0000000..5435ac3
--- /dev/null
+++ b/tests/unit/readHistoryUx.test.js
@@ -0,0 +1,200 @@
+/**
+ * READ HISTORY — the product surface for the one truth lineage owns.
+ *
+ * The load-bearing separation this suite defends: a lineage REVISION means the
+ * published CLAIM changed; the grade strike means the LETTER changed. They must
+ * never look, read, or be styled like the same event.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const ROOT = path.resolve(__dirname, '..', '..');
+const rh = require('../../web/src/lib/readHistory');
+const ancestry = require('../../src/services/read/readAncestry');
+
+const COMPONENT = fs.readFileSync(path.join(ROOT, 'web/src/components/vyndr/ReadHistory.tsx'), 'utf8');
+const ROUTE = fs.readFileSync(path.join(ROOT, 'src/routes/ancestry.js'), 'utf8');
+const LEDGER_PAGE = fs.readFileSync(path.join(ROOT, 'web/src/app/ledger/page.tsx'), 'utf8');
+
+const chain = (over = {}) => ({
+ ancestry: {
+ state: 'LINEAGE_AVAILABLE',
+ revisions: [
+ { snapshot_row_id: 1, revision_ordinal: 0, action: 'ORIGIN', change_type: 'INITIAL_PUBLICATION', captured_at: '2026-08-30T01:02:44Z', published_grade: 'C', published_p_win: 0.567, published_line: 4.5 },
+ { snapshot_row_id: 2, revision_ordinal: 1, action: 'REVISION', change_type: 'MARKET_REPRICE', captured_at: '2026-08-30T03:01:43Z', published_grade: 'C', published_p_win: 0.567, published_line: 4.5 },
+ { snapshot_row_id: 3, revision_ordinal: 2, action: 'REVISION', change_type: 'BELIEF_CHANGE', captured_at: '2026-08-31T03:47:09Z', published_grade: 'C-', published_p_win: 0.515, published_line: 4.5 },
+ ],
+ recaptures: [],
+ chronology_complete: true,
+ unrecorded_publications: 0,
+ publications_before_recording: 0,
+ ...over,
+ },
+});
+
+describe('the history reads as history, not as a database', () => {
+ test('ORIGIN, REVISION and change types get plain words', () => {
+ const v = rh.mapAncestry(chain());
+ expect(v.available).toBe(true);
+ expect(v.entries.map((e) => e.label)).toEqual(['First published', 'Updated', 'Updated']);
+ expect(v.entries[1].what).toBe('The price moved');
+ expect(v.entries[2].what).toBe('The read changed');
+ });
+
+ test('the newest entry is marked current, and it is worded, not only coloured', () => {
+ const v = rh.mapAncestry(chain());
+ expect(v.entries[2].is_current).toBe(true);
+ expect(v.entries[0].is_current).toBe(false);
+ expect(COMPONENT).toMatch(/CURRENT/);
+ });
+
+ test('each entry carries what was PUBLISHED then, from the authoritative row', () => {
+ const v = rh.mapAncestry(chain());
+ expect(v.entries.map((e) => e.grade)).toEqual(['C', 'C', 'C-']);
+ // Sourced from model_snapshots via the projection, never from lineage metadata.
+ const svc = fs.readFileSync(path.join(ROOT, 'src/services/read/readAncestry.js'), 'utf8');
+ expect(svc).toMatch(/published_grade: row\.grade/);
+ });
+
+ test('no backend vocabulary reaches the user', () => {
+ // Only the RENDERED strings. The lookup keys are ORIGIN/REVISION/RECAPTURE
+ // by design — they are how the code addresses an action, not what a person
+ // reads, and asserting over them tests the wrong thing.
+ const rendered = [
+ ...Object.values(rh.STATE_COPY).filter(Boolean).flatMap((c) => [c.headline, c.detail]),
+ ...Object.values(rh.ACTION_COPY).map((c) => c.label),
+ ...Object.values(rh.CHANGE_COPY),
+ rh.summariseRecaptures([{}, {}]).label,
+ ].join(' | ').toLowerCase();
+ for (const w of ['lineage', 'natural key', 'ordinal', 'digest', 'graph', 'recapture', 'origin', 'chronology']) {
+ expect(rendered).not.toContain(w);
+ }
+ });
+
+ test('the feature is not called Lineage anywhere a user can see', () => {
+ const visible = COMPONENT.split('\n').filter((l) => !l.trim().startsWith('*') && !l.trim().startsWith('//') && !l.trim().startsWith('/*'));
+ expect(visible.join('\n')).not.toMatch(/>[^<]*[Ll]ineage[^<]*);
+ expect(COMPONENT).toMatch(/READ HISTORY/);
+ });
+});
+
+describe('RECAPTURE is summarised, never destroyed', () => {
+ test('repeated unchanged observations collapse to one honest line', () => {
+ const v = rh.mapAncestry(chain({ recaptures: [{ captured_at: 'a' }, { captured_at: 'b' }, { captured_at: 'c' }] }));
+ expect(v.recaptureSummary.count).toBe(3);
+ expect(v.recaptureSummary.label).toMatch(/Rechecked 3 times with no change/);
+ // The chronology itself is unchanged — nothing was removed from the data.
+ expect(v.entries).toHaveLength(3);
+ });
+
+ test('the API still returns every recapture — the UI summarises, the contract does not', () => {
+ const act = (o) => ({
+ id: 1, read_id: 'r1', read_natural_key: 'k', lineage_action: 'ORIGIN', claim_digest: 'd',
+ revision_ordinal: 0, lineage_state: 's', lineage_version: 'lin@1',
+ claim_schema_version: 'claim@1', digest_algorithm_version: 'x', publication_id: 'p', ...o,
+ });
+ const out = ancestry.classifyAncestry([
+ act({ id: 1 }),
+ act({ id: 2, lineage_action: 'RECAPTURE' }),
+ act({ id: 3, lineage_action: 'RECAPTURE' }),
+ ]);
+ expect(out.recaptures).toHaveLength(2);
+ expect(out.recapture_count).toBe(2);
+ });
+});
+
+describe('truth states', () => {
+ test('legacy says history was not recorded — not that nothing happened', () => {
+ const v = rh.mapAncestry({ ancestry: { state: 'LEGACY_UNVERIFIED' } });
+ expect(v.available).toBe(false);
+ expect(v.headline).toBe('History not recorded');
+ expect(v.detail).toMatch(/started keeping Read history after/i);
+ expect(v.entries).toEqual([]);
+ });
+
+ test('unavailable is NOT presented as legacy', () => {
+ const v = rh.mapAncestry({ ancestry: { state: 'LINEAGE_UNAVAILABLE' } });
+ expect(v.headline).toBe('History unavailable');
+ expect(v.headline).not.toBe(rh.STATE_COPY.LEGACY_UNVERIFIED.headline);
+ expect(v.detail).toMatch(/should have a history/i);
+ });
+
+ test('invalid fails closed into a restrained state and names nothing scary', () => {
+ const v = rh.mapAncestry({ ancestry: { state: 'INVALID_LINEAGE' } });
+ expect(v.available).toBe(false);
+ expect(v.entries).toEqual([]);
+ // The USER-VISIBLE copy, not the machine `state` field the component keys off.
+ const shown = `${v.headline} ${v.detail}`.toLowerCase();
+ expect(shown).not.toMatch(/corrupt|invalid|fork|defect|integrity/);
+ });
+
+ test('an incomplete chronology never claims to be complete, and says WHICH hole', () => {
+ const before = rh.mapAncestry(chain({ chronology_complete: false, publications_before_recording: 2 }));
+ expect(before.incomplete).toBe(true);
+ expect(before.incompleteNote).toMatch(/before VYNDR started recording/i);
+
+ const failed = rh.mapAncestry(chain({ chronology_complete: false, unrecorded_publications: 1 }));
+ expect(failed.incompleteNote).toMatch(/couldn’t be recorded/i);
+
+ const both = rh.mapAncestry(chain({ chronology_complete: false, publications_before_recording: 1, unrecorded_publications: 1 }));
+ expect(both.incompleteNote).toMatch(/Some earlier updates aren’t recorded/i);
+
+ expect(rh.mapAncestry(chain()).incomplete).toBe(false);
+ });
+});
+
+describe('THE SEPARATION — claim changed is not letter changed', () => {
+ test('the history never uses the grade strike styling', () => {
+ expect(COMPONENT).not.toMatch(/line-through/);
+ expect(COMPONENT).not.toMatch(/revised_from_grade/);
+ expect(COMPONENT).not.toMatch(/GradePill|gradeShift|GradeShift/);
+ });
+
+ test('"Updated" never asserts the grade moved', () => {
+ const v = rh.mapAncestry(chain());
+ // ordinal 1 is a pure reprice with the SAME letter on both sides.
+ expect(v.entries[1].what).toBe('The price moved');
+ expect(v.entries[1].grade).toBe(v.entries[0].grade);
+ expect(JSON.stringify(rh.ACTION_COPY).toLowerCase()).not.toMatch(/grade|letter/);
+ });
+
+ test('the ledger card still renders the grade strike, untouched', () => {
+ expect(LEDGER_PAGE).toMatch(/revised_from_grade/);
+ expect(LEDGER_PAGE).toMatch(/textDecoration: 'line-through'/);
+ expect(LEDGER_PAGE).toMatch(/
Loading…
} + + {transportError && !loading && ( ++ {transportError} +
+ )} + + {view && !loading && !transportError && !view.available && ( ++ {view.headline} +
+{view.detail}
+{e.what}
+ )} ++ + {e.grade ? <> · published {e.grade}> : null} + {e.line != null ? <> · line {e.line}> : null} +
++ {view.recaptureSummary.label} +
+ )} + + {view.incomplete && ( ++ {view.incompleteNote} +
+ )} + > + )} +