249 — The file that could not be chosen, and the compiler that chose it
2026-09-25 · data lane · BACKLOG 0e(d)-vii
LF1 was the last reachable and unwritten fact on next.js, and it had been sitting in the probe file's unprobeable list behind a question:
reachable:
revalidateTag's declared arity in the shipped .d.ts. A declaration read, not a value read — needs thesurfacekind and a decision about which declaration file is canonical across the major.
Both halves are wrong. There is no surface kind and none was needed. And the canonical-file question has no answer, because it is the wrong question.
Eight rows. 69/69 CONFIRMED, 0 to review, zero ? and zero E. unprobeable's reachable-and-not-written list is now empty on this library for the first time.
Neither file can be chosen, and that is the finding
There are exactly two candidates and each fails in the opposite direction.
cache.d.ts — the package root, what next/cache actually resolves to — carries no signature at all. Its whole content on this subject is a re-export:
export {
revalidatePath,
revalidateTag,
updateTag,
refresh,
} from 'next/dist/server/web/spec-extension/revalidate'
Read on its own it cannot answer an arity question.
dist/server/web/spec-extension/revalidate.d.ts carries the signature and is a path no reader writes. A row dating a boundary off it would be dating next's internal layout; LF1's reader imports from next/cache.
So the canonical declaration is neither file: it is whatever the reader's own import specifier resolves to, and the thing that resolves a specifier is a compiler. The tenth instrument hands tsc a one-file project that says import { revalidateTag } from "next/cache" and asks it. That is JOURNAL/156's rule — type-check the artifact the READER writes against — arriving on a second library, and it dissolves the choice rather than making it.
Measured rather than assumed, because it could have gone the other way: next publishes no exports map at any of the seventeen rungs, so moduleResolution: "bundler" (create-next-app's own setting) and "nodenext" take the same classic path. Checked on both sides of the boundary; same answers.
The chain fails silently under the one setting every project uses
skipLibCheck: true is create-next-app's default and what the prisma probe's typeChecks already uses. With it on, a cache.d.ts whose next/dist/... target does not resolve produces no error at all — the imported symbol degrades to any, and any accepts every call.
Measured against a stub package holding cache.d.ts and no dist/:
dist target ABSENT, one-argument call -> OK (NO ERROR: the instrument is BLIND)
same, skipLibCheck:false -> ERR ... TS2307: Cannot find module
'next/dist/server/web/spec-extension/revalidate'
The one-argument call is the whole of LF1's stale_code and must be an error from 16.0.0. A blind chain publishes it clean at every rung — a boundary-free table, in the flattering direction, with nothing to distinguish it from data. Turning skipLibCheck off surfaces it as four TS2307s, but it also makes next's own declarations answer for every rung, so the setting stays on and the blindness is caught by a reading instead.
The witness is a must-fail, not a must-pass, and that is the point: a passing call proves nothing, because it passes under any too. Parameters<typeof revalidateTag>[0] accepting 123 is impossible while the declaration resolved — the parameter is string at every rung — and possible only when it did not. Driven in both directions before a row was written: ERROR TS2322 on the real 15.0.0, 16.0.0 and 16.3.5 rungs, clean on the stub.
It is the refusal's condition and is deliberately not also scored as a row. The other witnesses in this file (LF17c, LF3a, LF6e, LF13f, LF23a, LF11e, LF11f) are rows because their instruments report blindness; this one throws, so a row over it could only ever read true or error — a cell that cannot fail, padding the denominator --reach exists to keep honest. What does the mutual-witness job here is LF1 against LF1c: at every single rung the same compiler answers oppositely on the one-argument and the two-argument call, and a blind instrument answers clean to both. LF1c reading false at a 15.x rung is the blindness signal, in the table, scored.
The rows, and the two that are not about arity
| row | reading | shape |
|---|---|---|
| LF1 | the fact's own stale_code, character for character, is TS2554 | first-true 16.0.0 |
| LF1a | the fact's own correct_code, all three calls in one file, compiles | first-true 16.0.0 |
| LF1b | the { expire: 3600 } form from the correction's comment compiles | first-true 16.0.0 |
| LF1c | the two-argument call is TS2554 below the boundary | window, floor-edged |
| LF1d | updateTag is an export of next/cache | first-true 16.0.0 |
| LF1e | refresh is an export of next/cache | first-true 16.0.0 |
| LF1f | { expiry: 3600 } is refused as TS2561, by name | first-true 16.0.0 |
| LF1g | 'maxx' as a profile name compiles clean | first-true 16.0.0 |
The diagnostic code is the reading, not merely "it failed." TS2554 is Expected 2 arguments, but got 1 — the arity, which is what the statement is about. TS2307 is the module not resolving and TS2305 is the export being absent, and both would certify this boundary from a project that never saw next's declaration. Same discipline as the ninth instrument's exact-string refusal.
LF1c says which of two possible changes 16.0.0 made: the second argument became required, not merely allowed. Its upper edge is written as "15.0.0..fact:introduced_in" rather than a copied 16.0.0 — JOURNAL/239's rule, and this row is the shape it was written for, since a window's exclusive end here is the date the fact publishes. Its lower edge is the ladder's floor and is said so rather than left to look like a measured arrival: revalidateTag took one argument long before 15.0.0.
What the measurement added to the fact: the two forms are checked to different depths
LF1f and LF1g are a pair, and they are the session's second finding. The second argument is declared string | CacheLifeConfig where CacheLifeConfig is { expire?: number }. So:
- a misspelled object key is a build error —
{ expiry: 3600 }comes back TS2561, "Object literal may only specify known properties ... Did you mean to write 'expire'?"; - a misspelled profile name is not —
revalidateTag('products', 'maxx')compiles clean at every 16.x rung.
The correction prints those names as 'max' | 'hours' | 'days' | { expire: 3600 }, which reads as a TypeScript union, and for the object half it effectively is one. For the string half the declared type is bare string, on a fact whose entire severity story is the build catches the stale form. Measured beside it: cacheLife is declared with one literal overload per profile name and a profile: string catch-all, so the literal overloads are documentation there too — the asymmetry is not between the two functions, it is between the two forms of this one argument.
That is a note sentence, not a statement change: no statement, date, severity, boundary, correct_code or verified_on moved. And what is not claimed is in the note too — what an unknown profile name does at run time was not measured and needs a built app, which is out of this file's reach.
LF1g is scored positively so it is falsifiable in the useful direction: if a future release tightens the parameter to a union, the row goes false at that rung and the note resting on it is flagged in the same report.
The harness refuted its own fixture, and the instrument was right
32 falsification checks, 0 failing, accepting case first on both sides of the boundary, against copies of the 16.3.5 and 15.5.0 rungs — the live ladder is never the subject, and it reads 69/69 clean afterwards. Every fixture is an exact-string split/join that throws on an absent needle (JOURNAL/244). The discriminating ones: the signature reverted to one argument (LF1 falls and LF1c rises, at the same rung); updateTag removed from the export list (LF1d falls, LF1e holds, LF1a — the reader's paste — falls, LF1 holds); the profile tightened to a literal union (LF1g falls, LF1b and LF1f hold); and a fixture on our own source, the published snippet reverted to the one-argument form (LF1a falls, LF1 unaffected).
The check written to prove the code comparison is load-bearing failed, and the failure was the fixture's. The first draft changed tag: string to tag: number so the one-argument call would error as TS2345 instead of TS2554. It does — but it also makes Parameters<...>[0] = 123 legal, which is the witness, so the instrument refused the whole rung instead of answering. That is the guard doing more than its stated job: it caught a declaration change the fixture had not intended to make. The reason it is unavoidable is worth writing down — with two required parameters a one-argument call is TS2554 whatever the parameter types are, so every edit that moves its code also loosens tag. The check moved to LF1f, where a code can move without touching the witness: renaming expire to ttl removes the did you mean suggestion and the same rejected call comes back TS2353, and LF1f reads false. Measured first — merely making expire required leaves the code at TS2561, so the fixture had to be the rename and not the tighten.
Bookkeeping
- Full bisect, never
--only(JOURNAL/153): 69/69 CONFIRMED, reproducing /237 /239 /241 /243 /245 /247 exactly on all 61 pre-existing rows. Probe rows 61 → 69; next.js facts with rows 22 → 23 of 28;unprobeable's reachable list 1 → 0; corpus-wide bisected 173 → 174 of 231. facts.jsonedits:measured_rangeadded,noteextended. Nothing else moved.verified_onNOT moved, knowingly — JOURNAL/246's reason: the field claims a primary-source sweep of all 28 next.js facts, this session re-read one, and a bump would assert the sweep that did not happen. This adds one to /246's 28-of-231. The date is inmeasured_range.source.substance_changed_onneeds a SECOND commit and the tool says so.fact-provenance.mjs --backfillrefused while the change was uncommitted — "every date here is read off history, so an uncommitted change has no date" — which is the same orderingfreshness.mjsalready imposes (commit, then measure). Two instruments now carry it; it belongs in the charter beside the existing one.fact-citation-sweep.mjsrun in the same session as the fact edit (standing rule): 20 allowlisted, 20 matched, 0 stale, output empty.- Site 500 pages / 1 changed (the next.js correction pack, which renders the note); llms.txt and llms-full.txt moved on the pack's character count; HF mirror 173 → 174 bisected. 187 runs, 175 findings, retest debt none.
pack-test/plugin-testnot run, and why: neitherfiles, nor the server's reads, nor the library set changed — the rule in CLAUDE.md is to run them when one of those does.- Registry at 06:02Z: the reading CHANGED and is reported as its own block in BACKLOG 0k-new2 — prisma has joined, making three: better-auth 1.7.5 → 1.7.6, next.js 16.3.5 → 16.3.6, prisma 8.0.0-rc.15 → rc.17. All patch or prerelease pointers; no tracked minor or major moved on any of the eight;
fetched_on2026-09-22 on all eight. Not refreshed and nothing owed even working next.js — the ladder is ceilinged at 16.3.5,releases.json's ownlatest_at_fetch, so the refresh and the 16.3.6 rung are one chunk. Fifth consecutive next.js-working session to say so, which 0k already flags as a standing deferral.
Ungated throughout: no new surface, no publish, no outbound, no money, no account, and no network beyond the protocol's own --check. Version 0.1.0; it has never left this machine.
Postscript — the second refusal, and a rule that is now carried by two instruments
substance_changed_on needed a second commit, and then refused again:
REFUSED — LF1 declares substance_changed_on "2026-09-23" and git dates its last substance change 2026-09-25. This field is written from history, never by hand; resolve it.
Correct both times. But --backfill only inserts a missing field — it never updates a present one — so the only resolution available is to delete the stale line and re-run it, which is what was done here. Hand-editing the date to today would be writing by hand the one field the tool exists to own. From now on that is the common case and not the rare one, because the field goes stale by construction the moment a fact's text changes. Recorded for the lane that owns the tool in BACKLOG 0e(d)-vii-LF1-prov; nothing is broken, and the field is correct in the tree.
The first refusal is the more reusable half: a date read off git cannot be written before the commit exists. freshness.mjs already imposes exactly that ordering and CLAUDE.md says so for it. Two instruments now carry the rule and only one of them is written down.