178 — The ladders that were only their own directories

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

This session's assignment was one probe row. The clause-coverage sweep built two sessions ago (JOURNAL/176) had reported emoji as a token valibot LF2's statement names and no row touches, and the backlog put it first because it was the cheapest thing on the list: eleven rungs already on disk, one row to write, one bisect to run.

The row exists, it is measured, and the fact it audits survives unchanged. But the eleven rungs were not on disk. Neither were the other 149.


What the backlog believed, and why it was a reasonable thing to believe

BACKLOG 11k-w-a-i-orig says this, and it is careful:

Checked rather than assumed, because a reboot plausibly clears %TEMP% and this item's cost estimate rests on it — all five scratch trees survived: sp-zod-bisect 21 entries, sp-prisma-bisect 38, sp-vb-bisect 11, sp-tsc593, sp-1h-tarballs 3.

Every one of those counts is correct. The directories are there. The conclusion drawn from them is wrong, because a count of directory entries is not a measurement of an install — and this repository has a name for that mistake. tools/lib/identifiers.mjs exists because an absence and a blind instrument return the same value; a grep that cannot see returns false, which is exactly what a grep that looked and found nothing returns. Here it is the same rule one layer further down. An empty directory and an installed release are the same value to ls.

The census, taken with find -type f rather than ls, across all thirty-one sp-* trees under %TEMP%:

treedirectoriesfiles
sp-prisma-bisect32,2530
sp-ba-bisect19,0780
sp-zod-betas3,4710
sp-zod-325real3,0940
sp-rr-bisect1,2890
sp-anthropic7830
sp-zod-bisect5970
sp-a2probe5210
sp-benchmark-ladder5000
sp-vb-bisect4511
sp-baseline-a113288,504
(twenty others)1–6560

Twenty-nine of thirty-one trees hold no file at all. Thirty-two thousand directories under the prisma tree and not one byte of prisma in any of them. sp-tsc593 — the compiler four prisma probe rows reach for — is empty too. The eleven files in sp-vb-bisect are the bisector's own _bisect-import.mjs shims, written by this morning's run, not library code. Something swept the files and left the skeleton; sp-baseline-a11 is the one tree that survived whole and nothing in this session explains why it was spared.

The half of it that is not the backlog's fault

tools/lib/rung.mjs decides that a rung is installed with exists(p.dir) — fs.existsSync on the package directory — and it does so in both of its rules. resolveRung is the bisector's: per rung, the first layout whose directory is there. resolveLayout is the gate's, and its whole purpose is to refuse a ladder with a hole in it, because a boundary read off a half-built ladder can be the rung where the npm tree changed.

A shell is not a hole. Both predicates called all eleven valibot shells installed. The run printed

valibot — fact bisect over 11 installed releases

above a grid of eleven columns of ?.

Nothing published a wrong boundary, and it is worth being precise about why, because the protection held: the import fails, subjectsFor marks the rung unbuilt, the cells come out ? rather than F, and every row landed on NEVER_TRUE instead of a date. That is JOURNAL/108's rule working exactly as designed — a dropped rung is never silently a result. What was missing was not safety, it was diagnosis: the operator is handed eleven repetitions of Cannot find package … and has to infer from them that the tree is a shell and how much of it to rebuild.

So the second thing this session built

tools/audit/ladder-check.mjs. Offline, reads the scratch trees and the probe files, writes nothing, reports and does not gate, and not in the pre-commit chain — for the same reason git-refs-check.mjs is not: this fault stops a session before anything is written, and a commit-time gate would convert a diagnosis into lost work.

Four verdicts per rung, and the middle two are the ones an existence check cannot separate:

And the differential is output, not a footnote. For every rung the tool prints its own verdict beside what rung.mjs's predicate says about the same rung, and the selftest asserts that the two disagree on a shell. That assertion is written to fail if rung.mjs is ever taught to read the manifest — which would be an improvement, and which should send whoever makes it to this file and this entry rather than leaving a stale paragraph behind. The selftest builds all four shapes rather than describing them, and separately asserts that a tree that was never built reports never-built with zero rungs: a ladder nobody has built and a ladder something emptied are different facts, and keeping them apart is the whole point.

Its first run:

  zod            SHELL        21 rungs — 21 shell
  prisma         SHELL        31 rungs — 31 shell
  valibot        USABLE       11 rungs — 11 installed
  better-auth    SHELL        46 rungs — 46 shell
  react-router   SHELL        51 rungs — 51 shell

149 shells of 160 rungs. valibot is usable only because this session rebuilt it.

Two backlog premises are corrected by that table. react-router's ladder is not "the one not installed" — 11k-w-a-i-a says its ladder "does not exist" and queues LF2/LF3 behind the cheaper rows on that basis. It exists, as a declared 51-rung ladder and a swept tree, in exactly the state the other three are in; react-router is no longer the expensive one, because they are all the expensive one now. And the tool's own first draft read prisma as 23 rungs where the bisector walks 31: bisect-facts.mjs composes ladder ∪ bisectorRungs, and reading only ladder produced a count that was right about the wrong question — this file's own bug, in the same shape as the one it was written to catch, caught by comparing its output against a number CLAUDE.md already stated.

The row itself

valibot LF2 says the ReDoS "in the regular expression behind the emoji validation action" was fixed in 1.2.0, and that you should require >= 1.2.0 if you validate user-supplied text with the emoji action. The probe timed EMOJI_REGEX. A caller never touches EMOJI_REGEX; a caller writes pipe(string(), emoji()). That the two are the same object was documented and never measured, and they can come apart in either direction — an action that stopped using the export, or an export the fix moved without moving the action.

So LF2 is now a conjunction of two rows over one shared input constant, which is deliberate: the regex row and the action row must not be able to drift apart on what they send, because their whole value is the comparison.

They agree on all eleven rungs — both false at 0.42.0, 0.42.1, 1.0.0 and 1.1.0, both true from 1.2.0. The boundary is the action's boundary and not only the regex's, and the advice the pack publishes is about the object it names. The full ladder re-run (required by CLAUDE.md for any row change) is 59/59 confirmed, 0 to review.

The fact did not move. Its note records the split and the agreement, because how a published claim was verified is part of the claim. The clause-coverage sweep goes 380 → 381 tokens touched; uncovered clauses 122 → 121.

The near-miss, and it is the one already written down

The first attempt to rebuild the ladder reported eleven successful installs and left eleven empty rungs. The installer's absolute path was written into a Bash heredoc, the backslashes were eaten, C:\Users\Samue\AppData\Local\Temp\sp-vb-bisect became the relative path UsersSamueAppDataLocalTempsp-vb-bisect, and mkdirSync(..., {recursive: true}) created it — inside the repository working tree, where npm then installed the entire valibot ladder. The success check was existsSync(package.json) and it was telling the truth about the wrong directory.

That is CLAUDE.md's last rule, the one JOURNAL/174 paid for: run destructive shell commands against a path you have proved exists, because a mangled path does not fail, it relocates the work into this repository. It cost nothing this time — git status showed one untracked directory, it was read before it was removed, and nothing was committed — but it is the second time in one day that a silently-rewritten path has put foreign bytes in the working tree. The generalisation the rule needs: it is not only cd and not only $TMPDIR. Any absolute Windows path that passes through a shell heredoc on the way to a tool arrives as a relative path rooted at the repository. Write such paths with forward slashes, or build them with path.join, and verify by reading back the path the program actually used rather than the one it was given.

What the next session inherits

The data lane's cost model is void. Every item in the backlog whose estimate begins "cheap, the ladder is on disk" is now the price of a ladder: zod 21 rungs, prisma 31 (plus the tsc install four of its rows need), better-auth 46, react-router 51. valibot is the only one standing, and it cost about seven seconds a rung for a dependency-free package — prisma's rungs are not that.

Run node tools/audit/ladder-check.mjs before costing any probe work, not after.