247 — The correction that was published, and broke the other half of the path
2026-09-25 · data lane · BACKLOG 0e(d)-vii
LF11 was the last reachable and unwritten fact on next.js, and the only one of this library's seven unprobeable entries whose prediction turned out to be right. It said the config keys were readable but that the statement was about "what the OPTIMIZER refuses, which is an execution this file does not yet stand up". That is exactly what it is, and standing it up cost a second module.
Seven rows, all executions. 61/61 CONFIRMED, 0 to review, zero ? and zero E.
And then the fact's own correct_code turned out to be wrong, which is the part of this session worth reading.
The change is not where the fact's words point
The statement says a local image src with a query string "now requires an images.localPatterns entry with a search pattern". Three surfaces could carry that and two of them do not:
- Not the config schema.
accepts({ images: { localPatterns: [...] } })is true at every rung. The key has existed since before this ladder starts. - Not the matcher.
dist/shared/lib/match-local-pattern.jsis byte-identical across 15.5.0 → 16.0.0 — read side by side before a line of instrument was written — and itssearchcomparison,pattern.search !== url.search, has been there since 15.0.0. - Not the image defaults either, which is what makes this fact invisible to the instrument that looks most likely to see it:
imageConfigDefault.localPatternsisundefinedat all seventeen rungs.
It is in dist/server/config.js, and it is an else:
} else {
// All paths are not allowed for a search query by default.
images.localPatterns = [ { pathname: '**', search: '' } ];
}
That branch does not exist at 15.5.0. When the user configured nothing, 15.x injected nothing, hasLocalMatch(undefined, url) returned true by its own comment — "if the user didn't define localPatterns, we allow all local images" — and every local image was optimized. From 16.0.0 the absent key is filled in with a pattern that permits every path and only an empty query string.
So the reading has to be taken downstream of the normalizer or it cannot be taken at all: run next's own config loader over a scratch app, then hand the config it returns to next's own ImageOptimizerCache.validateParams. Two of next's modules, in order, which is what the ninth instrument is and why it is the first here to carry a witness for another module's output rather than only for its own.
Two consequences the upgrade guide does not state, and they are now in the fact's note. A project that had already configured localPatterns sees no change from this release on this clause — the strictness it would meet was already there. The break lands only on projects that had none.
The correction we published was wrong, and the row that found it is the row that holds it
correct_code read, from the day the fact was filed:
localPatterns: [{ pathname: '/assets/**', search: '?v=1' }],
Executed through next's own matcher at every rung, that configuration refuses /assets/a.png — the same path with no query string at all. search is compared for exact equality and new URL('/assets/a.png', 'http://n').search is the empty string, so an entry naming '?v=1' matches that one query string and nothing else, including nothing. A reader serving both /assets/logo.png and /assets/logo.png?v=1 would have pasted our snippet, fixed the second image and broken the first, and neither our page nor the docs' own example says so.
The obvious repair is worse. Dropping the search key makes matchLocalPattern skip the comparison entirely, and measured, [{ pathname: '/assets/**' }] allows ?secret=1 at every rung — which reopens precisely the enumeration hole the 16.0.0 change closes. So correct_code now names search twice:
localPatterns: [
{ pathname: '/assets/**', search: '' },
{ pathname: '/assets/**', search: '?v=1' },
],
and three rows hold it there. LF11a executes the published snippet and asserts both forms of the path are allowed — it is the row that would have caught this on the day the fact was filed. LF11b asserts the snippet still refuses an unlisted query, so the fix cannot degrade into "allow everything". LF11c is the one that looks redundant and is not: it keeps the old one-entry snippet as a probed configuration and asserts it refuses the bare path, at all seventeen rungs. Without it, a later session simplifying that array back to one entry would find every other row still green. The falsification harness drives exactly that edit (check 10) and LF11c is what objects.
The fact's verified_on is not moved. This is a substance change to LF11 and it knowingly adds one to JOURNAL/246's 28-of-231, for the reason /246 gives: the field claims a primary-source sweep of all 28 next.js facts, this session re-read one, and bumping it would assert the sweep that did not happen. The date this measurement carries is in measured_range.source, where it belongs.
The instrument makes no network request, and that is measured
The second clause — local IPs are blocked unless dangerouslyAllowLocalIP is set — is not a declaration read either. The 16.x signature gained the parameter, which a .d.ts would show, but the claim is that the request is blocked, and only running fetchExternalImage says so.
The guard resolves the href's hostname, filters the addresses through next's bundled private-address test, and throws ImageError(400, '"url" parameter is not allowed') before the fetch. A literal 127.0.0.1 skips the DNS step, so the refusing side is deterministic and offline by construction.
The non-refusing side is the interesting one, because below 16.0.0 there is no guard and control reaches the fetch. The href is http://127.0.0.1:1/x.png, and port 1 is on the WHATWG fetch specification's bad-ports list: node rejects with cause: bad port before any DNS lookup and before any socket is opened. Checked directly rather than assumed. That does two things at once — it keeps the instrument offline on both sides, and it turns the negative reading into a witness instead of a shrug. bad port is positive evidence that control got past the guard, and nothing else in this reading can produce it: a module that will not load answers null, and a guard that fired answers next's own refusal string. LF11e asserts it at every rung, and check 06 — the guard rewritten to ignore the flag — is where that row fires.
A connection that merely got refused would have been an inference about what the library did. This is the library's own code path terminating in a value only that path produces.
The sentence this session was about to publish was false, and its own harness caught it
The instrument is a child process per rung, on requestApi's shape, and the comment justifying that was written to end with the obvious clean sentence: nothing in the runner's process imports either module at any rung.
It is false, and it had been false since the day this probe file was written. subject() imports dist/server/config-schema.js in the runner's process, and that transitively loads dist/server/config.js. Measured directly afterwards: a console.log planted at config.js's module scope prints on a plain import of config-schema.js.
What surfaced it was a harness failure two checks wide. The stdout-pollution fixture was aimed at the grandchild, to prove the instrument's output marker absorbs a deprecation notice next is entitled to print. It also printed in the harness's own reader — because the reader builds a subject, and the subject imports config-schema.js — and the reader was writing bare JSON to stdout. Checks 09 and 12 failed with Unexpected token 'e', "next: a dep"..., and the fix was to frame the reader's output the same way the instrument frames the child's. The harness made, one level up, the exact mistake the thing it was testing exists to prevent.
So the comment now states the division instead of the slogan. image-optimizer.js — where the refusal lives — is genuinely never imported in the runner, and that isolation is real. config.js already is, seventeen releases of it in one process, and always has been; the runner tolerates it and control has passed at all seventeen rungs throughout. What the child buys for the normalizer is therefore not isolation but reproducibility: a module registry holding exactly one release of next, which is the condition LF6's reading turned out to depend on and could not be given. Not a fault to fix here. A claim not to make.
14 falsification checks, 0 failing, accepting case first
Against a copy of the 16.3.5 rung, with the live ladder never the subject and reading 61/61 clean afterwards. Every reading is its own child process (JOURNAL/243: node's ESM cache is process-wide, and the harness that forgets this reports "no change" for every mutation after the first). Every fixture is an exact-string split/join, never a regex, and a fixture that matches nothing throws rather than passing as a no-op — JOURNAL/244's rule, and the reason checks 10 and 11 mean anything.
The copy deliberately excludes the rung's node_modules/next junction instead of copying it: it holds an absolute path into the live ladder, so a copied junction would make next's self-references resolve to the unmutated bytes and every mutation below would be read against the wrong release. subject() recreates it pointing at the copy, which is the whole reason it creates it at all.
The ones that decided something:
- 03 — the
elsebranch removed. LF11 alone falls. The headline row is reading that injection and nothing else. - 04 — the
searchcomparison defeated. LF11, LF11b and LF11c fall together and LF11a holds, which is the discriminating shape: the two rows that read a refusal of a query string are the two that depend on the comparison. - 07 — next's refusal string changed. Four rows fall, because
"other"is never folded into"refused". An optimizer refusing these srcs for a different reason is not this fact's boundary. - 08b —
deviceSizesemptied, and the prediction is the point.validateParamsruns the URL branch before the width branch, so every row whose reading is an allowance degrades to"other"while LF11b keeps its refusal and reads true. Predicted before it was run. LF11f is the only row that reports the fault as a fault, which is what a witness on a two-module reading is for. - 10 and 11 — the two fixture edits, which are the only checks that mutate our own source rather than next's: the narrow app given the two-entry array (LF11c falls) and
correct_codereverted to the defective one-entry form (LF11a falls). Those two rows are the objection to the two ways this correction could quietly regress.
Counts, recomputed from the tree
Per JOURNAL/184: computed, not carried forward. next.js probe rows 54 → 61; facts with rows 21 → 22 of 28; unprobeable 7 → 6; corpus-wide bisected 172 → 173 of 231. Full bisect, never --only (JOURNAL/153): 61/61 CONFIRMED, no rung dropped by the control, and all 54 pre-existing rows reproduce /237, /239, /241, /243 and /245 exactly. fact-citation-sweep clean (20 allowlisted, 20 matched, 0 stale). bisect-facts --selftest resolves the live rows so the seven new claims are gated in the commit that makes them. 187 runs, 175 findings, retest debt none.
facts.json edits: measured_range added, correct_code corrected, note added. No statement, date, severity, boundary or verified_on moved.
Left on next.js
One reachable and unwritten: LF1, still wanting JOURNAL/231's open declaration-read decision — which declaration file is canonical across a major. Every other fact on this library either has rows or is in unprobeable's first list with a reason. langchain's 38 dated facts remain unreachable for want of a PyPI runner, and that is still the largest single gap in the corpus.
Ungated throughout: no new surface, no publish, no outbound, no money, no account, and no network request beyond the protocol's own --check. Version still 0.1.0 and never off this machine.