259 — The ladder installed by hand, and the compiler nobody checked

2026-09-25 · data lane · BACKLOG 0e(d)-vii-prisma-ladder

prisma's 31 rungs are installed. ladder-check.mjs reports USABLE 31 of 31, 0 shells anywhere in the tree, and the only rung in the corpus that is not installed is zod 3.25.0, which is unloadable for the publisher's own reason and is refused rather than retried. The last thing blocking BACKLOG 0e(d)-vii-claims is gone, and the item that was going to be four minutes of npm turned out to be carrying a second fault that nothing in the tree could see.

The install is now a tool, because "by hand" is what cost three days

tools/audit/probes/prisma.mjs has carried the sentence "the bisector's ladder is installed by hand and matches it" since 2026-09-09. On 2026-09-22 a %TEMP% sweep took every file out of all thirty-one rungs (JOURNAL/178's fault, diagnosed by ladder-check.mjs in JOURNAL/185). What "by hand" then meant in practice: 0e(d)-vii-claims finished four libraries on 09-24 and 09-25 and stopped on the fifth for three days, on an install that is seven seconds a rung, because reconstructing the invocation from a probe file's comments is the expensive part and no session had a reason to pay it.

So tools/audit/ladder-install.mjs exists, and the design rule is that it holds no second copy of any fact the bisector reads:

factimported fromwhat a copy would have cost
which tree a ladder lives inTREES, ladder-check.mjsinstalling a ladder nobody reads, reporting success
which rungs are walkedladderOf(), ladder-check.mjsprisma's five densifying rungs (6.11.0–6.15.0, JOURNAL/152) left shells — 26 of 31 is a pass to any count and a hole to the bisector
where a rung goesrungPaths(), rung.mjs, per-rung-aliasthe gate's per-rung-real layout, invisible to the tool that asked
the npm package namedata/<lib>/releases.json's packagenext.js is package next; seven of eight agree and the one that does not breaks silently

Two of those four were live rather than hypothetical, and --dry-run on the real tree shows both: ladderOf returns 31 for prisma where the probe file's own ladder export returns 23, and packageNameOf("next.js") returns next. The selftest asserts both by number, so a future rewrite that reaches for the probe file directly fails on a count rather than on a judgement.

What it refuses is the half worth reading. It never touches a rung classifyRung calls installed, and it refuses an unloadable rung by name — zod 3.25.0 ships a tarball that is src/ and nothing else while its manifest points into ./dist/ (JOURNAL/185), so the install is exactly what the publisher shipped and reinstalling changes nothing. ladder-check made that a separate verdict precisely so the next session would not retry the install four times; a repairer that treated it as a shell is how that happens, and the dry run on the real zod tree prints 1 refused … reinstalling changes nothing here.

The closure differential, and why a shell is worth reading before deleting it

A shelled rung still has its directory skeleton — 32,253 directories under sp-prisma-bisect and not one file — and that skeleton is a record of what the pre-sweep install resolved. So the tool reads the top-level names under a shell's node_modules before removing it, and compares the new closure against them afterwards. It reports and never gates, because npm's resolution legitimately moves.

30 of 30 rungs reproduced the pre-sweep top-level closure exactly. The reinstall rebuilt the ladder that was measured in JOURNAL/152 and /153, not a different one.

Confirmed by a second instrument, deliberately: a shell script reading a snapshot of all 31 skeletons taken by hand before any install agrees at 31 identical, 0 differing. Its 31st row is self-comparison and carries no information — 6.0.0's skeleton was destroyed by this session's own hand install, before the tool existed, while the recipe was being measured — so the differential is 30, and 6.0.0 is corroborated only by the reading in the transcript (23 names, the same 23 as its 6.1.0–6.3.0 siblings). Stating it as 31 would have been the flattering direction.

And the closure is not constant across the ladder, which is why this is a per-rung comparison and not something anyone could have asserted from one reading: 23 top-level names at 6.0.0–6.3.0, 28–29 across 6.4.0–6.7.0, 25 through 6.12.0, 70 at 6.13.0, 50 through 6.19.0, 105 from 7.0.0, and 151 at 7.9.0–7.10.0.

Cost, measured rather than estimated: ~7s and ~127MB a rung, so 31 rungs is about four minutes and ~4GB. Both numbers are in the tool's does: string in argv.mjs, where a caller sees them before typing the command.

The second fault: the tree named sp-tsc593 held TypeScript 7.0.2

Both probe files that type-check resolve $SP_TSC to a default of <temp>/sp-tsc593/node_modules/typescript/bin/tsc — a directory whose name is the version. Checked before running anything type-level, because prisma's typeChecks rows need it:

$ node <temp>/sp-tsc593/node_modules/typescript/bin/tsc --version Version 7.0.2

Dated from the tree: on 2026-09-23 00:10 something ran npm i typescript there and npm wrote "typescript": "^7.0.2" into the manifest. The tree named for 5.9.3 had held the native port for two days.

The warning already existed, and that is the finding. BACKLOG 2f-iii, written 2026-09-03 (JOURNAL/040): "Environment, and it will break the next audit if ignored: pin typescript@^5. npm install typescript now gives 7.0.2, the native port, whose . export is lib/version.cjs — no ts.createProgram, no ts.ModuleResolutionKind." Twenty days later the tree was overwritten with exactly that. The warning was a paragraph in a backlog stanza and nothing in the tree was a check, which is this repository's oldest lesson arriving at its own environment.

Four guards looked at that path and all four passed. Three in prisma.mjs and one in next.js.mjs, each spelled if (!fs.existsSync(TSC)). Measured on the exact state, replayed from a fixture:

fs.existsSync(<the 7.0.2 bin>) -> true

Why the silent direction is the one that matters

node-api-audit.mjs uses the compiler's API and dies loudly under 7.x — Cannot read properties of undefined (reading 'NodeNext'). That half announces itself. The probe files spawn bin/tsc as a CLI, and 7.0.2 runs: it type-checks, it emits diagnostics, it exits 0 or 2. A probe row whose verdict moved would have moved for a reason that is not the library, and the bisect would have printed CONFIRMED against a date measured by a different compiler.

Nothing published is wrong. The two prisma facts that name the compiler in their own note — LF1 (JOURNAL/154) and LF1e (JOURNAL/158) — describe a 2026-09-12 measurement, eleven days before the overwrite, and zod's claims text names 5.9.3 about an older one. The exposure was forward: any session re-running those rows after 09-23 would have published a reading its own fact's note misdescribes.

The repair, and the check that makes it stick

The tree was repaired to hold what its name says (npm install --save-exact typescript@5.9.3), which also un-broke node-api-audit.mjs — ts.createProgram is a function again and ModuleResolutionKind.NodeNext is 99.

tools/lib/tsc.mjs is now the one place that decides which compiler measures this corpus. It exports the expected version, one spelling of the path (prisma wrote process.env.TEMP ?? "/tmp" and next.js wrote os.tmpdir() for the same directory — two expressions that agree on this machine and are not the same expression), and one refusal. All four call sites now go through it and zero existsSync(TSC) guards remain.

Three details it was worth being careful about:

Proven to fire rather than asserted to: a fixture at 7.0.2 was pointed at by SP_TSC and the refusal came out of the probe file, on a real LF9 row, naming both versions and the 09-23 date — and tsc.mjs --selftest passes 11 checks, of which one is live against whatever is on this machine right now, so the next session that empties or repins that tree is told by a selftest and not by a wrong number.

The refusal carries the 2026-09-23 date and the BACKLOG 2f-iii reference in its own text, because the next reader of it should not have to re-derive what this session spent twenty minutes deriving.

The bisect, and the third thing a ladder turns out to be

The full run on the reinstalled ladder — HEAD's probe file, no --only, under 5.9.3 — came out 114/117 confirmed, 3 to review, exit 0. Timed: 2131s, 35.5 minutes. BACKLOG had carried "~29 minutes" off JOURNAL/153 and this session had no reason to doubt it; the figure is now measured on 31 rungs on this machine, and it is the real reason this item and the conversion cannot share a session.

Two of the three failed for a missing FILE, not for a fact, and that is the finding:

>> LF35a invariant INVARIANT_BROKEN error @6.0.0: no registry snapshot for @prisma/sqlcommenter-query-insights — expected sqlcommenter-versions.json beside the ladder; this probe cannot answer without it >> LF35 metadata NEVER_TRUE (the conjunction LF35a && LF35b, dragged down)

ladder-check.mjs said USABLE 31 of 31 and two rows still could not answer, because a ladder is not only its rungs. prisma.mjs reads a registry snapshot from <tree>/sqlcommenter-versions.json — npm's own version list for LF35's companion package — and its comment says exactly what it is: "fetched once by the ladder script and committed nowhere: it is an input to the run, like the installs." The sweep took it with everything else. The ladder script it names does not exist; ladder-install.mjs is now the nearest thing to one and did not know about it.

The probe's own construction is what made this cheap to diagnose instead of expensive: LF35a throws when the snapshot is absent rather than returning false, so the cells read E and the report named the missing file and its expected path. That was written on 2026-09-08 after the same input went missing and every cell read F for want of it — the report then said NEVER_TRUE about a fact whose text is right. Second occurrence of the same missing input, and the instrument built after the first one paid for itself here.

Snapshot refetched (336 versions, 8,919 bytes) and the three rows re-run as a subset:

ok LF35 metadata claimed 7.1.0 observed 7.1.0 CONFIRMED ok LF35a invariant claimed null observed null INVARIANT_HOLDS ok LF35b types claimed 7.1.0 observed 7.1.0 CONFIRMED

So the ladder's reading is 116 of 117, and the one row left is LF27 — NO_CLAIM, by construction and documented in the probe file: include: is rejected at every rung, so its absence has no boundary, and the conjunction deliberately carries no claims entry because the sibling LF27c (where:, 7.4.0) is what dates the trap.

116/117 is composed from two runs and is stated that way on purpose. Only LF35, LF35a and LF35b read the snapshot, so the other 114 rows cannot have moved — but nobody has yet seen that number come out of one full run, and a number assembled by argument is not the same artefact as a number that printed. The next session on 0e(d)-vii-claims needs a single full baseline run anyway, since the method's differential is two full reports compared byte for byte, so it will produce it.

Counts and gates