239 — The date the probe copied, and the rule that was two rules

2026-09-24, data lane. BACKLOG 0e(d)-vii's deferred half.

Two sessions in a row deferred the same item, both for the same stated reason: the fact bisector has no selftest, it is shared by all seven ladders, and it is only verifiable by re-running real bisects, so the fix was "its own chunk and not a line in this one" (JOURNAL/235, /237). This is that chunk. It closed with one mechanism, one pre-commit link, two measured false positives that narrow the rule the deferring sessions were building, and a population of 152 rows that is now visible for the first time.

Nothing published moved. No fact, no date, no severity, no boundary, no measured_range. 187 runs, 175 findings, retest debt none.

The defect, in one sentence

claims.LF21 = "16.3.0" and claims.LF22 = "16.3.0" in tools/audit/probes/next.js.mjs were copies of replacement_available_from from the facts those rows test, and nothing joined them. A session correcting either fact edits facts.json, the correction pack, the rules file, the Hugging Face row and the site — and the probe's claim stays behind. The row then goes on measuring the old date and reports CONFIRMED against it.

That is the worst direction a failure here can take. The bisector exists because two zod facts carried wrong introduced_in dates written from release notes (JOURNAL/066); a stale claim makes the instrument that catches a wrong date certify one.

A claim may now name a field instead of copying it

claims[id] accepts "fact:replacement_available_from", and either half of an interval may be symbolic: "fact:introduced_in..fact:replacement_available_from". Resolution is against the row's own parent fact, at run time, in bisect-facts.mjs. Three rules, and the middle one is the one the backlog asked for by name:

  1. The field vocabulary is closed. fact:intoduced_in is refused by name rather than resolving to undefined — coverage.mjs's rule, because a typo that reads as an absent value is the shape this whole change is against.
  2. A field the fact does not carry is a loud failure, never a fall-through to introduced_in. Falling through is exactly how a row would keep scoring against the wrong date after a correction removed the field, and it is the ?? chain that scored zod LF28b against a date it never claimed (JOURNAL/074).
  3. A resolved value must still be a release, because a malformed one reaches the window and restoration arithmetic as a string comparison, and an interval with an undefined edge scores every rung outside itself.

Resolution runs for every entry in the map before a single rung is loaded, even under --only: an unresolvable claim is a defect in the file rather than in the row being run, and a subset run that skipped the check would print a clean verdict over a file nothing can trust.

Seven next.js rows were converted — LF21, LF21a, LF21c, LF22, LF22a, LF28's upper edge, LF16a's upper edge. The full bisect (never --only, JOURNAL/153) reproduces JOURNAL/237's reading exactly: 32/32 confirmed, 0 to review, zero ? and zero E cells, every symbolic row resolving to the value it held as a literal and keeping its verdict.

Driven until it fired, and the falsification is the point

Eleven checks against a real copy of the tree (tools/ and data/ copied to scratch; the live tree is never the subject, and the copy's files are asserted byte-identical afterwards):

One of those eleven failed on the first run, and the failure was in the report rather than the resolver. --claims printed only rows that errored or carried a drift note, so a clean symbolic row printed its resolved date nowhere, and the check asking "does the claim move when the fact is re-dated?" could not read the answer off the output. Ten of eleven passed and the one that failed was the instrument. Symbolic rows are now always shown; inherited rows still are not, for the reason in the next section.

In the pre-commit chain, for identifiers.mjs's reason

node tools/audit/bisect-facts.mjs --selftest is now a chain link (24 checks, 0.09s, offline, writes nothing). The bisect itself still needs an installed ladder and stays out.

The selftest's live half is what earns the place: it resolves every claim in every probe file against every facts.json — 506 rows today, 7 of them symbolic. So a correction that removes or re-dates a claimed field is refused in the commit that makes it, rather than at whatever bisect happens to run next. Driven against a broken tree the link exits 1 and names both affected rows (LF21 and LF21c); against the clean tree it exits 0. A witness check asserts at least one real row is symbolic, because a differential over zero symbolic claims cannot tell a working resolver from an unused one — identifiers.mjs's witness rule, one layer up. And a stale allowlist entry is a failure: every entry in both allowlists must still match a live row, driven until it fired.

The rule two sessions were building is two rules

JOURNAL/235 reached, and /237 confirmed on a second fact: where a fact carries replacement_available_from, its headline statement is dated by that field and not by introduced_in. Read as a law about the field, it is wrong, and the reverse check written this session found the two cases in the corpus that break it. Both were measured rather than argued:

So the field does two jobs: it dates a statement, and it floors a snippet. The pack renders the second (build-corrections.mjs — "The code below needs … or later"); a row claims the field only where the statement's own API is what arrived there. Applied as a law, the rule would have moved two correct rows off dates that 58 rungs of bisect confirm. Both are allowlisted with their bisect readings as the reason, so the report's expected output is the allowlist and nothing else.

What the reverse check found that nobody had counted

A declared literal that equals a field of its own fact is either a transcription that should be symbolic or an independent event that coincides with the fact's date. The tool cannot settle that, so it lists them and the list is now measured for the first time:

libraryrowsdeclared claimssymbolicliteral = a field of its fact
better-auth19704
next.js321070
prisma11755028
react-router5441039
tailwindcss102200
valibot6031028
zod12268053
total5062147152

152 rows, five libraries, and converting them is not this chunk. Each one is a judgement about the library — does this row's date follow the fact's field, or was it measured independently and happen to land on the same release? Converting a coincidence is the mirror defect: a later correction would be carried silently into a row that measures a different event. next.js LF17e is exactly that case and is allowlisted with its reason (it dates the 16.0.0 removal, which is also LF17's own date). A mechanical batch over 152 rows would be JOURNAL/232's "a batch rather than a decision" with the blast radius pointed at the dates this Index publishes.

The first version of that check named 300-odd rows and every one of them was already correct: it did not distinguish a declared literal from an inherited claim, and an inherited claim reads fact.introduced_in at run time — it is already joined, and a correction already moves it. A report whose majority is false is one nobody reads twice; the same instinct is why the raf check suppresses facts where a sibling row already claims the later date (better-auth LF9's LF9a/LF9b pair and zod LF16's LF16/LF16b pair already split the two events, which is the answer LF21/LF21a reached on next.js).

Readings