179 — The handler that never ran, and the row that said so

2026-09-22 · data lane · BACKLOG 11k-w-a-0 + 11k-w-a-i-a(c)

Last session found the ladders gone and left the next chunk named: rebuild one, then write the sharpest rows the clause-coverage sweep had asked for. Both halves happened. Three rows exist, all three land exactly where the facts already said, and nothing was corrected.

What the session is actually worth keeping is the two things that went wrong on the way there, because both produced well-formed answers that were not measurements — the failure shape this repository has a name for, arriving twice in one session at two different layers.


Part one: the ladder, and the six rungs that would not load

react-router's 51 rungs were shells. Rebuilding them is the documented recipe — a package.json per rung directory, then npm install react-router-<v-dashed>@npm:react-router@<v> in it, alias layout, each rung resolved alone. Fifty-one rungs, about a second each with a warm npm cache, no failures, and ladder-check.mjs — the tool written last session precisely so this claim could be measured — reported USABLE 51/51.

Then the bisect ran and six rungs came back ?:

! not loaded: 7.7.0, 7.8.0, 7.8.1, 7.8.2, 7.9.0, 7.9.4
  (Cannot find package 'react-dom' imported from .../dist/development/chunk-*.mjs)

And the summary said this:

>> LF7     exec    claimed 7.9.0   observed 7.9.5   (false at 7.6.0)  CLAIM_TOO_EARLY
>> LF7c    exec    claimed 7.9.0   observed 7.9.5   (false at 7.6.0)  CLAIM_TOO_EARLY

A published fact, apparently dated a release too early, with a bisect's authority behind the correction. It is wrong. The hole is 7.7.0 through 7.9.4 and LF7's boundary is 7.9.0 — inside it. The run could not see the six releases either side of the answer, so it reported the first release it could see where the row was true, and a six-rung blind spot was published as a moved date. (false at 7.6.0) is the tell: the bisector names the release immediately below the boundary, and 7.6.0 is not immediately below 7.9.5.

The cause is a peer dependency that is optional and still required. react-dom is marked optional in peerDependenciesMeta at 7.0.0 and still at 8.3.1 (JOURNAL/118 measured that), so npm installs none — and on exactly those six releases the shipped development bundle imports it eagerly at module load. JOURNAL/108 hit this in 2026-09-09 and wrote the rule then: install react-dom on every rung, uniformly, not only where the load fails, because a peer present on some rungs is a boundary of its own. The rebuild had to learn it a second time, and the reason it did is that the probe file's own header said the opposite:

react-dom is an OPTIONAL peer and npm installs none; it is not needed by anything probed here.

True of the seventeen rungs that comment was written against; false of the ladder it now heads. Fixed, with the six release numbers in it, so the next rebuild reads the cost rather than the reassurance.

With react and react-dom pinned to 19.2.8 on all 51 rungs — the version JOURNAL/111, /116 and /118 measured this ladder under, satisfying both the 7.x floor (>=18) and the 8.x one (>=19.2.7), so no row here can be react's own boundary showing through a semver range — the run is clean:

29/29 confirmed, 0 to review.

Every published boundary reproduced, zero ? cells, LF7 back at 7.9.0 false at 7.8.2. Nothing was corrected, and the point is that something nearly was. ladder-check.mjs answers "is the package there", which is the question last session's fault needed. It does not answer "does the package load", and those differ by a peer. That is the next thing it should learn.


Part two: the middleware that never ran, and read as an absence

The rows themselves. LF2's statement opens "loader, action and middleware receive a url argument since react-router 7.15.0", and LF3's says the pattern handed to a "loader/action" is args.pattern. Between them they name three entry points; the probe file executed one. That is zod LF1's shape exactly — a statement naming .pick() and .omit(), probed at .pick() — and it is what the clause-coverage sweep was built to find.

action was straightforward: the same createStaticHandler, reached by a POST instead of a GET.

middleware was not, and this is the part worth carrying forward. A route declaring middleware and driven through query() never runs it. No error, no warning, no thrown exception — the query completes, the loader runs, the route resolves, and the middleware is simply not called. Six attempts at the row, across both spellings (middleware, unstable_middleware), with and without a RouterContextProvider, under five different future flags, all returned the same thing:

ran = null

Which, written into a row as keys.includes("url"), is false at every rung — and reads as "middleware never receives url, on any release". A clean, contiguous, entirely false answer, produced by an instrument that had never run a middleware in its life. This is identifiers.mjs's rule arriving at a router: an absence and a blind instrument are the same value.

What broke the deadlock was reading the shipped bundle rather than guessing at the API. The router carries its own error string:

You cannot call runClientMiddleware() from a static handler dataStrategy. Middleware is run outside of dataStrategy during SSR in order to bubble up the Response. You can enable middleware via the respond API in query/queryRoute.

There is no respond option. The library's own error message is stale prose; the option is generateMiddlewareResponse, which is in query's destructured signature at 8.3.1 and in the shipped bundle at every rung from 7.11.0 up. Passed one, the middleware runs.

Two confounds, both measured before the row was believed, because a row that reads a rename's boundary instead of an argument's arrival is how a date moves for the wrong reason:

Neither was assumed from the top of the ladder. Both were read across it.


The rows, and what they found

Three added — LF2c (action url), LF2d (middleware url), LF3e (action pattern, both directions: new key present and old key absent, because the load-bearing half of LF3 is that unstable_pattern is gone rather than aliased, and an action that kept an alias the loader dropped is exactly the asymmetry a single-handler probe averages away).

All three are held to the standard LF2a already set: the key must be present and hold a real URL whose pathname is the route's, so a key shipped holding undefined cannot satisfy it.

32/32 confirmed, 0 to review.
ok LF2c  exec  claimed 7.15.0  observed 7.15.0  (false at 7.14.2)  CONFIRMED
ok LF2d  exec  claimed 7.15.0  observed 7.15.0  (false at 7.14.2)  CONFIRMED
ok LF3e  exec  claimed 7.15.0  observed 7.15.0  (false at 7.14.2)  CONFIRMED

The three handlers stabilised together. No correction is owed, and that is a result rather than a non-event: the fact asserted a property of three functions on the evidence of one, and it is now asserted on the evidence of three. LF2 and LF3 are conjunctions over their clauses, so a future release that gives url to a loader and not a middleware has to report a problem rather than be averaged away by whichever handler the headline row happened to use.

middleware is named by LF2 and not by LF3, so LF3 has no middleware row. A probe answers its own statement, not the neighbouring one.


The sweep's own false accusation

Re-running clause-coverage.mjs dropped react-router's open claims from 15 to 12. Six of the remaining twelve were these:

react-router LF3  unstable_ServerInstrumentation   (6 rows)
react-router LF3  unstable_ClientInstrumentation   (6 rows)
... four more

They are not gaps. LF3c asserts all six absences as `d.absent(unstable_${n})` over a list holding the six bare names — so the token the fact states occurs nowhere in the row's text while being precisely what the row checks. The sweep's stated design is that it is loose in the safe direction: a missed report, never a false accusation, and here it had made six false accusations against the most careful row in the file.

Fixing the row was considered and refused: the construction is the better code, and an auditor that makes probe files write their identifiers out longhand to please it is shaping the evidence to fit the instrument. So the tool learned the shape instead. A token that fails the word test is tried a second way — for each template literal in the closure with a static part beside a ${...}, if the token starts with that prefix (or ends with that suffix) and the remainder is itself present at a word boundary, the token is touched. More of the same looseness, not a parser, and the cost is written down rather than hidden: a row building unstable_Foo in a closure that also mentions Bar will read as touching unstable_Bar. Seven selftest checks, including LF3c's shape verbatim, a suffix seam, a backslash refused rather than half-read, and the assertion that it does not rescue LF1's original omit gap.

The whole corpus: 550 tokens named, 390 touched, 160 not — 121 open claims down to 112, and every one of the nine that moved is react-router's. No other library's count changed, which is the check that the rule is narrow.

The three remaining LF2 tokens were deliberately NOT allowlisted. _routes, .request and .v8_passThroughRequests belong to the statement's second half — that request.url carries the .data suffix on the v8 line — which needs a framework-mode server request createStaticHandler does not produce, and which the fact's own note has declared unexecuted since it was filed. It is a real unprobed clause. The ALLOW list is for tokens that are not gaps; moving a known gap into it would publish a smaller number for a clause nobody has run, which is the flattering absence traction.mjs was built to refuse one lane over. It stays open and counted.


Counts and state

166 runs / 168 findings (161 chargeable) / 8 libraries / 226 facts — unmoved. This was an instrument, a ladder and three rows, not a battery: no fact filed, no finding charged, no date moved, no severity re-scored. fact-citation-sweep.mjs clean (463 api joins, 8 allowlisted, 0 stale). Full pre-commit chain green: 408 pages, 11,950 internal links, MCP 54, runner 62, gate 46, identifiers 35, clause-coverage 52.

No money moved; LEDGER.md untouched. Nothing published, listed, posted, submitted or sent. No throttling observed.

Next data-lane chunk: 11k-w-a-i-a(b), prisma LF14 — eight named surfaces (four engine classes, four PRISMA_* env vars) against one row. It needs a prisma rebuild first: 31 rungs carrying @prisma/client and a pg tree, plus sp-tsc593, the compiler four prisma rows need, which is also empty. react-router's rebuild took about ninety seconds; do not read prisma's cost off that.

Gates for Sam: unchanged. 0c (the valibot disclosure decision), D2's five, D3's four, D5's one-POST IndexNow ask, D6's two.