266 — The directory two programs read, and neither had looked at
2026-09-26 · data lane · BACKLOG 0e(d)-vii-prisma-ladder-e
The item at the top of the open list was a stanza about build-site.mjs's data copy loop being unfiltered, with a reach estimate attached:
Reach today: zero. Nothing but
.jsonfiles sits under anydata/<lib>/, and -d deliberately did not put anything there.
There are 38 .md files under data/<lib>/, in seven of the eight libraries. Every one is served. Every one is linked from its own run page. They have been there since the first battery.
So the session's first output is not a repair, it is a correction to the item, and the correction matters because the item's own first-named fix — "filtering to .json" — would have unpublished all 38 files and broken a link on 38 pages. JOURNAL/264 closed with the obvious fix was the dangerous one; this is the same sentence one item along, and this time the reason it was dangerous is that the item described a directory without listing it.
What is actually in there, measured
| class | count | declared by | served |
|---|---|---|---|
facts.json | 8 | the file map | yes |
releases.json | 8 | the file map | yes |
<run>.json | 187 | the run schema | yes |
<run>.md | 38 | nothing, anywhere | yes |
The write-ups are the working notes — the thing the run pages offer as "write-up (markdown)". Each is declared by its own run record's markdown field, and build-site.mjs renders that field as a link. 38 of 38 paired in both directions, zero orphans, zero dangling declarations.
The fourth class was real, published, and written down in no file map, no schema and no comment. That is the finding, and it is what let the item be wrong: build-index.mjs skipped every non-.json entry, so the program that validates this tree could not see a class of file the program that publishes it copies.
build-index.mjs if (!file.endsWith(".json")) continue; // .md: invisible ... else runFiles.push(...) build-site.mjs for (const f of readdirSync(dir)) // .md: copied write(data/${lib}/${f}, read(...));
Two readers, two rules, and the disagreement is exactly peerSpecs (JOURNAL/264) and coverage.mjs (JOURNAL/215): a rule held twice is two rules, and you find out when one of them is wrong about the tree they share.
The other two claims in the item were wrong in the flattering direction
Both measured rather than argued, by dropping the fault into data/zod/ and running the build.
- "any non-JSON file added there is silently served" — false.
NOTES.txtand a stray.md:
FAIL — 2 served file(s) no agent can reach — no llms.txt line, no sitemap entry, and no indexed page links them. data/zod/NOTES.txt data/zod/stray-notes.md
That is build-site.mjs's agent-reach audit, written for the AI-SEO doctrine and not for this, failing the build and naming both files.
- A run declaring a
markdownthat does not exist is caught the same way, by the internal link checker, also by name.
So of the item's three faults one was real and loud (a subdirectory is an EISDIR stack trace) and two were already covered by instruments written for other reasons. This is a naming change and not a coverage change, and tools/lib/data-entry.mjs's header says so rather than leaving a later session to infer a guarantee from the existence of a gate. What changed is when and in what words:
| fault | before | after |
|---|---|---|
| a subdirectory | EISDIR stack trace, mid-publish, after bytes were written | a sentence naming the directory, in build-index.mjs --check, the chain's first link |
| a stray file | agent-reach audit at the end of a full site build | same place, by name |
| an orphan write-up | same audit, same place | same place, by name |
a dangling markdown | internal link checker, end of a full site build | same place, by name |
data/index.json and site/ are byte-identical to HEAD across the whole change. The refusals moved; nothing published did.
What the module is
tools/lib/data-entry.mjs, in collection-date.mjs's shape: offline, reads nothing, writes nothing, a pre-commit link whose only mode is its selftest. A closed four-class vocabulary, classifyEntry for one entry and classifyDir for a listing, both refusing by name. isDirectory is a required argument with no default — the one refusal the module exists for cannot depend on a caller remembering a flag, which is existsSync's failure mode (JOURNAL/259). 32 checks, including a negative control (the classification this replaces, run against the same entries, returning SKIPPED for a write-up) and a positive control against the real corpus that asserts the 38.
That last one is the check that would have stopped the item's premise being written, so it is in the pre-commit chain rather than in a comment.
Two things found while wiring it, one of them worse than the item
(1) A refusal whose message nobody reads is the refusal not being there. The first wiring let classifyDir throw. That exits 1 with
at file:///C:/dev/unattended-works/tools/build-index.mjs:241:19
— an exit code and a filename, which is what the EISDIR it replaces already gave you. Caught in both callers now and reported in each build's own FAIL format. Fatal rather than accumulated: a library directory that cannot be classified has no run list, and carrying on prints a page of cascade errors about the consequences of the one problem already named.
(2) A RUN THAT FAILED ITS SCHEMA WAS CARRIED INTO THE DERIVATIONS ANYWAY, AND THE CRASH BEAT THE ERROR REPORT. Found by testing a claim I had written into the module header and had not measured. runs.push({ ...body, _file: rel }) ran unconditionally, 600 lines above the report. On a real run record with findings deleted:
TypeError: r.findings is not iterable at build-index.mjs:811
— a stack trace instead of the schema error this same loop had already computed and put in errors. The gate's entire output is that list, and the shapes that most need it were the shapes that could not reach it. Reproduced identically against HEAD (build-index.mjs:773) before changing anything, so it is the loop's defect and not the new classifier's. The fix is if (errors.length === before) runs.push(...): nothing is lost, because a non-empty errors exits 1 and writes nothing either way. After:
FAIL — 1 problem(s): - data/zod/opus-5.json: missing required property "findings"
How long it had been there is not established and is not claimed. The unconditional push is older than anything this session touched; nothing dates it.
And the claim I had to take back out of my own header
The draft said the change stopped "an unrecognised .json silently becoming a run". It does not, and it should not. Run filenames are free-form (opus-5-v2r-a.json), so a stray .json is indistinguishable from a run by name, and the vocabulary says so rather than pretending to a discrimination it cannot make: data/zod/notes.json classifies as run and is reported as an invalid run, which is the honest answer. else runFiles.push(...) was therefore never a fall-through in bisect-facts.mjs's sense, and calling it one was the same error as the backlog item's — describing this directory without looking at it, in a session that had just finished proving how that goes.
The population is five readers, not two
Measured, because the item said two:
| reader | rule | state |
|---|---|---|
build-index.mjs | own copy | converted |
build-site.mjs | unfiltered | converted |
build-corrections.mjs:103 | own copy, correct | not converted |
audit/fact-citation-sweep.mjs:240 | own copy, correct | not converted |
audit/hedge-sweep.mjs:321 | own copy, correct | not converted |
The three remaining all agree with the vocabulary today — checked, not assumed — so this is a consolidation with no defect behind it, which is why it is a backlog item (0e(d)-vii-prisma-ladder-h) and not this chunk. The two converted are the two that generate published surfaces, which is where a disagreement costs a published byte.
Registry check
node tools/fetch-releases.mjs --check: 3 of 8 behind, all eight held 2026-09-22 (4 days). better-auth 1.7.5 → 1.7.6, next.js 16.3.5 → 16.3.6 (patches 148 → 150), prisma 8.0.0-rc.15 → rc.17. This session did not refresh — all three are patch tails and an rc pointer, no stable minor or major, and a refresh is its own chunk (Operations: being behind obliges knowing and saying so).
Also observed, not repaired
A failed site build is not atomic. When build-site.mjs died partway through the probes it left site/ torn — zod's data directory and site/schema/ deleted, 200-odd pages half-written. A following successful build restores it exactly (verified: git status clean), so the blast radius is one session's confusion and never a published byte, and it is filed rather than fixed (0e(d)-vii-prisma-ladder-i).
And a probe that changes a page's bytes moves data/site-history.json, which a rebuild does not undo — the substantive-date ratchet records the change; restoring the source computes a hash differing from the stored one and stamps today again. git checkout is the only way back. Cost this session one restore; written into the backlog item so the next session experimenting on a page does not publish a date it invented.