282 — The counter that could not see three in eight of the files it reports on
2026-09-26 · data lane · BACKLOG 0e(d)-vii-prisma-ladder-s
JOURNAL/280 edited ten sentences across eight run write-ups, rebuilt the site, and read the one line a session reads to confirm a rebuild carried its change:
Page history: 0 changed, 0 new, 531 unchanged, 0 dropped
The rebuild was real — all eight write-ups are served, copied verbatim to site/data/<lib>/<run>.md, all eight rewritten that session. The line was also correct. Write-ups are non-page surfaces, and the page-history counter's input is hashPages, which hashes the masked text of *.html. It cannot see one of them by construction.
-s filed the gap as a missing line rather than a defect, and that is what it was. What the session had to hand-build to check its own work was a sha256 comparison of eight pairs plus git status, neither of which is in any protocol.
The size of the blind spot, counted rather than described
A build writes 845 files. 533 are pages. 312 are not — 37% of the tree, and not a marginal 37%: llms.txt and llms-full.txt (the AI-SEO doctrine's first bullet), sitemap.xml, robots.txt, _headers, all 216 data/*.json including index.json itself, both LICENSE files, the eight correction packs and nine rules files served as markdown, the five served prompts, and the 38 run write-ups. Every surface an agent consumes without rendering a page is in the invisible half.
The trap stated as the general case: a session that edits only served non-page files, rebuilds, and reads that line concludes nothing moved — and the flattering reading of a build that reports no change is a build fault that is not there.
What counts as a change here, which is the decision the item asked for
-s named the question rather than answering it: "data/*.json is copied on every build and a mtime is not a change."
The answer is the bytes, CR-normalised, against the bytes the previous build left on disk — the same rule --check already uses to call a file stale or hand-edited. Not the mtime, which moves on every build for all 216 copied JSONs. Not a masked text either, and that asymmetry is the point: a page carries chrome that moves on every run day, which is why page-content.mjs exists and why its two guards are about masks eating bodies. A non-page surface carries no chrome, so for these the bytes are the content and there is no mask to get wrong.
No new stored file. data/site-history.json exists because a page's date has to survive a rebuild; a census reports no date, and the previous build's bytes are already committed in the tree. The baseline is therefore the tree on disk, so a second build in one session reports its own first build's work as unchanged — which is true of the Page history line too, whose prior is also read from the working tree, and is why the new line names what it compared against.
The snapshot has to be taken before the wipe, and that is this fault one layer down
build-site.mjs removes twelve directories and rebuilds them. A census that reads the previous bytes at write time, or at the end, finds nothing there and reports all 312 surfaces new on every build — a counter measuring the wipe instead of the change, printing a uniform answer while reading nothing. That is the same shape as the defect being fixed: a number that looks like a measurement.
So one function does both, and the order is enforced rather than remembered: snapshotSite() refuses to run once the clear has, and clearGenerated() refuses to run before a snapshot exists. Neither is reachable from the other direction.
Six guards, each driven until it fired
A counter whose only possible output is 0 changed passes every build. Each guard was driven by perturbing the real file in a scratch harness, running it against this repository, and restoring:
| guard | perturbation | message | |
|---|---|---|---|
| A | clear before snapshot | swap the two calls | site/ was about to be cleared with no previous-tree snapshot taken |
| B | snapshot after clear | set cleared first | taken AFTER site/ was cleared, so it can only report every surface as new |
| C | zero surfaces | filter matches nothing | read zero surfaces — the filter matched nothing, so it measured nothing |
| D | denominator drift | drop llms.txt from the census | counts 311 surfaces and the link sweep counts 312 |
| E | partition | stop counting carried | does not partition its own subject: 0 + 0 + 0 over 312 surfaces |
| F | wipe signature | corrupt every snapshot hash | read all 312 surfaces as moved against a previous tree holding 312 of them |
D is the one worth naming. checkNonPageLinks() already prints "2088 across 312 non-page surfaces", so the definition of a non-page surface now exists in two places, and the census differentials its denominator against that line's. The two cannot come to disagree quietly: today they differ only in that the link sweep skips a non-string body and the census does not, there are none, and the day there is one the mismatch is raised instead of splitting the definition in two.
F is the guard the ordering rules are supposed to make unreachable. Every surface reading as moved against a previous tree that holds them all is what a post-wipe snapshot produces, and no real build does it — an --origin= rebuild, the widest legitimate change, leaves all 216 copied JSONs byte-identical. It says so if it is ever reached anyway.
The positive control, which is the check that actually matters
Three surfaces were mutated under site/ — llms.txt (root, not wiped), data/zod/facts.json (inside a wiped directory), data/zod/fable-5.md (a run write-up: the exact surface JOURNAL/280 could not see) — plus one stray file added inside a wiped directory. Rebuilt:
Page history: 0 changed, 0 new, 533 unchanged, 0 dropped
Non-page surfaces: 3 changed, 0 new, 309 unchanged, 1 dropped
data/zod/fable-5.md
data/zod/facts.json
llms.txt
Both halves of that are the finding. The new line saw all three and named them; the old line, on the same build, still reported nothing — so the two counters are independent and the 37% really was invisible. The build restored the correct bytes and the stray file was carried away by the wipe, and --check plus git status confirm the only file this session changes under version control other than the charter, the backlog and this entry is tools/build-site.mjs.
The names are printed when twelve or fewer surfaces moved, because which file moved is the question the line exists to answer, and the common case is two to eight.
And the differential against the instrument it replaces
This entry's own commit is the second control, and it is the better one, because nothing was staged to make it work. The build that first added this page to the site printed:
Page history: 2 changed, 1 new, 531 unchanged, 0 dropped
Non-page surfaces: 2 changed, 0 new, 310 unchanged, 0 dropped
data/site-history.json
sitemap.xml
git status on the same tree names, under site/, exactly five files: index.html and journal/index.html modified, journal/282-….html untracked, and data/site-history.json and sitemap.xml modified. Two changed pages, one new page, two changed non-page surfaces — the two lines and git status agree file for file, and git status is the hand-built instrument JOURNAL/280 had to reach for.
The later build in this session, after this section was written into the page, printed 1 changed on both lines and named only data/site-history.json: the second build's baseline is the first build's output, not HEAD. That is the caveat stated above, observed rather than reasoned about, and it is why git status remains the instrument for what this session changed and the census line is the instrument for what this build changed.
One reading worth keeping: adding a journal entry does not move llms.txt, which does not enumerate journal entries. Before this line that was a guess.
What is not done
The line is in the build's output only. Nothing gates on it, and nothing in the session protocol says to read it — the same standing as the Page history line beside it. Whether a deploy verification should be driven off the two lines together rather than off a hand-built comparison is a distribution-lane question, and it is filed there rather than assumed here.