248 — The date that was nearly a claim nobody made
2026-09-25 · distribution lane · DISTRIBUTION D5, migration steps 1–3
Yesterday's entry (JOURNAL/246) found that verified_on — one date per library file, meaning "every fact in here was last checked against a primary source on this day" — was false on 6 of 8 libraries, and specified the repair in five steps. The field cannot be kept true by the sessions that write it: a session that corrects one fact and does not re-read the other 27 has done the right thing, and bumping the file's date would assert a sweep that never happened. The repair was to let a fact carry its own date.
This session built that, measured it before publishing it, and the measurement changed the field.
What the spec said, and the question it did not ask
Step 1 named the field verified_on and defined it as "the date THAT fact's statement, citations and code were last checked against a primary source." Step 2 said to backfill it from git rather than by hand, because fact-provenance.mjs already emits each fact's substance_changed — the committer date of the commit that last moved its substance — and "every one of the 28 came out of a commit whose message says what was re-read."
That last clause is the whole load-bearing claim, and it rested on one spot-check. So before the backfill was allowed to stand, the population behind it was measured: for each of the 29, which SUBSTANCE fields actually differed between that commit and the one before it?
| fields moved in the dating commit | facts |
|---|---|
note and nothing else | 14 |
severity/severity_label (+note), or also_known_as alone | 3 |
| a statement, citation, code or release moved | 10 |
| the fact was added in that commit | 2 |
17 of 29 — 59% — last moved in a commit that touched no statement, no citation, no code and no release, and 14 of those moved a note and nothing else. Their commit subjects are the giveaway: "DISTRIBUTION D4: the class that was a residue", "the row that read the wrong one of two published dates", "a severity measured against one of two entry points". Those are bisector results, page-rendering decisions and severity re-scores — measurements against installed packages. fact-provenance.mjs already refuses to call that a primary-source check one level up: its own METADATA entry for measured_range says so, and quotes the 231-fact commit that said so itself.
So the specified field, built exactly as written, would have published 17 verification claims nobody made — and published them the worst possible way, one row at a time into a corpus that gets concatenated, shuffled and batched.
The field that survives the measurement
substance_changed_on: the day this fact's text last changed. A claim about the record, read off git, vouching for nothing. Not a verification date, and the schema now says it was tried as one and refused, with the number.
Three consequences fall out of that name rather than being argued for:
Every fact carries it — 231, not 29. JOURNAL/198's rule is that the unit which travels is a row; a row carrying null here would be telling a reader to go and consult a file they do not have, which is the absence-versus-omission confusion the field exists to end. build-index.mjs requires it on every fact and the error names the tool that writes it rather than a schema path.
A fact row now carries two dates, because they answer two questions. verified_on is when a primary source was last consulted for every fact in the row's library file — the only verification claim either date makes — and substance_changed_on is when this row's own text moved. next.js/LF17, the sharpest instance in JOURNAL/246, now ships verified_on: 2026-09-02, substance_changed_on: 2026-09-24. That is the finding stated rather than collapsed. The CC BY attribution sentence cites the verification date, and the selftest asserts it: a record's mtime is not provenance.
The file-level field keeps its name and gets the only description it can support — the date of the last full sweep — with the sentence that it went false in the normal course of correct work.
The backfill is the instrument that found the drift
node tools/audit/fact-provenance.mjs --backfill, --dry-run to print the plan. Three properties it was built for:
It is not a reformatter. Two of the eight facts files do not round-trip through JSON.stringify(doc, null, 2) — next.js and zod are indented differently — so a parse-and-write backfill would have landed 231 real lines inside a whole-file reindent and nobody could read the diff that mattered. The edit is textual: find the fact's own "id" line, take the indentation from that line, insert one line after it. The differential is that deleting the lines the function says it added must give back the original file byte for byte — not "the line count is right", which is the check that cannot tell a surgical insert from a rewrite. It is exact-string surgery and not a regex, which is JOURNAL/188's rule and the same fix that entry ended with.
It is idempotent, and that is the classification doing it. substance_changed_on is METADATA, so writing it moves no digest, so the second run measures exactly what the first did. A backfill that changed its own input would be a ratchet nobody could stop. The classification is forced rather than chosen: a field whose own arrival counted as a substance change would oblige the next session to stamp a newer date for having stamped this one, and that loop has no fixed point.
Two refusals driven against the real tree, because a writer that has never refused has proved nothing: a fact with the date removed (build-index.mjs names it and names the tool), and a hand-typed date that disagrees with history (the backfill refuses rather than overruling a human — substanceOf's one-directional rule, applied to a value instead of a key).
Step 4 is narrowed and half of it is refused
Step 4 proposed the offer line say "the range of per-fact dates, or the newest". The newest is the flattering direction. A file whose oldest fact was checked on 2026-09-02 does not become a 2026-09-24 artefact because one row moved. For a single number the honest one is the floor — the sweep date the offer line already carries — so checkOfferProvenance is right as it stands and nothing is owed there. What remains real is the correction pack and the rules file, which print one date per library and could now print the range they actually contain; that is a claims-policy surface and a chunk of its own.
Step 5's objection is gone, and this is why it would not have been
fact-provenance.mjs's header refuses to install the obvious gate — refuse a commit that changes a fact's substance without moving its date — because a gate whose cheapest remedy is a lie is worse than no gate, and today the only remedy was bumping the file-level sweep.
Against a per-fact verified_on that objection would have survived, and the 17 are the proof: on 59% of the real cases the cheapest remedy would still have been to assert a source check that did not happen. The gate would have shipped looking installed and manufacturing false claims at exactly the rate the data lane does good work.
Against substance_changed_on there is no such case, for the reason the field was renamed: the remedy is "this text changed today, say so", which is true for every field in the SUBSTANCE class by construction, because the claim is about bytes and not about sources. The gate needs only HEAD and the working tree — one git show per library, O(1) in the history, unlike the report — so it belongs in the chain. Two things the session that writes it must settle rather than discover: the midnight boundary (the stamp is written before the committer date exists, so a commit that rolls over reports a one-day lag — bounded and visible, but it should be stated), and whether it fires on a fact added in the same commit, where there is no HEAD row to compare.
The limit, unchanged and repeated because the field is new
This instrument proves a date too early and never too late, and substance_changed_on inherits that only in the direction that matters: it is exact about when the text moved, and it says nothing whatever about when the text was last checked. The 202 facts whose text predates their file's sweep are not thereby verified more recently than that sweep, and the 29 whose text postdates it are not thereby verified on the day it moved. Both were true before today; what changed is that a row now says which of the two things it is telling you.
Nothing here is gated: no new surface, no outbound, no money, no account. 231 lines added to data/, none removed.