For months our sitemap was green. Every URL in it resolved. A guard compared the sitemap against the live site and reported zero problems every morning. The sitemap was also, at the same time, missing every indexable page that lived in a subdirectory of the webroot โ 23 of them, some with no inbound link from anywhere on the site.
Nothing was lying. The generator that wrote the sitemap and the guard that checked the sitemap shared one input: the generator's own file walk. A generator that skips a directory and a checker that only ever asks about the generator's output will agree with each other forever, and both will be wrong in the same direction. That is the whole post. The rest is what it looked like and what the fix has to touch.
The writer was a small Node script. The line that built its file list was:
const allFiles = fs.readdirSync(PUBLIC_DIR)
.filter(f => f.endsWith('.html'))
.filter(f => !f.includes('.bak'))
readdirSync does not recurse. Every page in a subdirectory was invisible to it. That is a one-line bug, but the interesting part is not the bug โ it is that the system had no way to notice it, because the only assertion in the neighbourhood was pointed the other way:
<loc> the sitemap submits must be a real page. This was checked, and it was true. It stayed true after the generator was fixed, too.Both directions look like "sitemap coverage," so it is easy to write one and believe you wrote both. They have different failure modes: the first catches URLs you publish and cannot serve (stubs, deleted pages, stale slugs); the second catches pages you serve and never publish. The second one is quieter. A page missing from a sitemap throws no error anywhere โ it is a 404-free, log-free, HTTP-200 event on every system in the chain.
The rule this cost us: a coverage assertion whose population is produced by the thing it is checking cannot fail. The population has to come from somewhere the writer does not control.
For a sitemap, that means reading the deployment tree itself โ the files as they exist on disk โ and comparing them against the published artifact, rather than asking the publisher what it published.
Measured on the deployed tree on 2026-09-28, the pages that had never been submitted were:
for/claude-cowork for/cursor for/grok for/agent-wallet-guide
guide/x402-agent-payments zh-cn/
sdk/python bridge/ chainsight/*
tools/tx-lookup tools/x402-tester whale-watch/ x402-tester/
marketplace/ genesis/* hackathon/* keeperhub-sentinel/*
repo/*/demo
Two of them, /guide/x402-agent-payments and /zh-cn/, had no inbound link from any served page either. Nothing could reach them: not a crawler, not a reader following the site. They were reachable only by someone who already knew the URL.
The replacement guard reads two things from two places that do not share a producer. The candidate population comes from the deployment tree, read over ssh, read-only. The published set comes from the sitemap, fetched over HTTPS from our own origin.
A file is reported as a finding only if all four hold:
noindex'd;<link rel=canonical>;<loc> values.Clause 3 is the load-bearing one. Sites legitimately contain pages whose canonical points somewhere else: a duplicate that consolidates into the canonical version, a thin alias, a page mid-migration. If the guard counted those, it would print "the generator's walk missed this page" for a page that is supposed to be absent from the sitemap, and send the reader to edit the generator โ which is behaving exactly as designed. Those files are counted and named under "not judged" instead of being silently dropped, so the narrowing is visible in every run.
Findings also split into two buckets with two different remedies, because the fix is not the same:
<loc> are two different spellings of the same file, both serving 200. Remedy: regenerate; if it persists, the clean path is shadowed by something else. Editing the page tag is the wrong first move.Merging those into one finding type would print a remedy that is wrong half the time, which is worse than printing nothing.
The live run, verbatim shape:
pages scanned : 736 (from the deployed webroot)
sitemap locs : 638
not judged : 93 (stub, or no on-domain canonical to judge)
canonical elsewhere : 13 (page consolidates into another file)
canonical dangling : 0
excused on the wire : 2 (canonical answers 301 โ the page tag is stale)
findings : 0 (walk-miss 0, top-level loc disagreement 0)
STATUS: clean
Fixing the generator moved the sitemap from 615 to 638 locs โ a pure addition, no removals, no duplicates. The two negative controls that matter here run on real bytes, not fixtures: replaying the guard against a backup of the old sitemap names exactly 23 WALK-MISS; against the live one it names zero.
When either read fails โ the tree unreadable, the sitemap unreachable โ the guard exits 3 and prints no count at all. That is deliberate. An unreadable corpus and a clean corpus must never print the same line, or "we could not measure" silently becomes "nothing to report."
After the guard was working, two of its four printed remedy lines turned out to be describing an older version of the system.
The LOC-DIFFERS remedy said: "move the page tag and the generator's <loc> together โ moving one alone just swaps which side is wrong." That was true when the generator derived <loc> from the filename. It stopped being true the moment the generator was changed to emit each page's own declared canonical; after that, moving the page tag alone was the whole fix, and the printed instruction sent a reader to edit a second file for nothing.
The lines had no test coverage โ not because anyone decided they shouldn't, but because tests were written for the predicates and the verdicts, and the remedy string was just text appended at the end of a branch. It rotted the ordinary way an uncontrolled line rots. Four controls were added that drive the shipped code path rather than the predicate functions, asserting that each remedy names the mechanism it is about and does not print the other bucket's remedy. Two negative controls on real copies: restoring the old text turns exactly one control red; removing the two gates turns exactly the two opposite-remedy controls red.
A remedy line is not a comment. It is an instruction that a reader follows, and a wrong one is worse than an absent one, because it produces a confident edit to the wrong file.
This is an operational note from running minia2a.uk, a pay-per-call API marketplace where agents pay in USDC on Base over x402. The generator, the guard and the controls described above all run against that site's deployment tree; the pattern is not specific to sitemaps.