Read History: the graph becomes something a person can read

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
This commit is contained in:
Kev
2026-08-31 00:42:37 -04:00
parent cdb01f97a2
commit 9dda9df132
12 changed files with 734 additions and 5 deletions
+30 -1
View File
@@ -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(', ');