277 — The counter that would have been wrapped one layer too high
2026-09-26 · distribution lane · DISTRIBUTION D9
JOURNAL/275 ended by filing the cheapest item D9 had: "count a tool's metered requests IN THE TOOL. One counter around each jget, printed at the end and stated in the same unit does: uses, so a session can verify a cost line instead of differencing a counter that resets mid-run."
This session built it. tools/lib/request-budget.mjs, wired into all four network tools, 29 checks in the pre-commit chain.
And the seam the item named is the wrong seam, which is a thing this item could only find by spending two requests on the live API instead of reading the code.
The measurement, and why it is a measurement and not another lower bound
JOURNAL/275's structural finding was that api.github.com/rate_limit's used cannot be differenced across a run: the limit is a rolling hour anchored at the window's first request, so it can reset inside the chain, and every one of D9's budget figures — 42, 71, 81, 49 — is that difference. The repair is to read reset on both sides. If it has not moved, no window boundary fell between the readings and the difference is exact:
before used=0 remaining=60 reset=1790424690
fetch https://api.github.com/repos/dotansimha/graphql-yoga
status=200 redirected=true url=https://api.github.com/repositories/110819939
after used=2 remaining=58 reset=1790424690 <-- DELTA 2, reset unchanged
One jget call. Two billed requests. fetch follows the 301 itself, so the extra hop never reaches a wrapper placed above it. A counter around jget would have printed 1.
The same argument holds a second way, and it was already in the two files the item pointed at: demand.mjs and probeability.mjs both retry once on a 429 inside jget. A retry is a second request. So the counter goes around fetch, where a retry is counted without anyone remembering to, and it follows redirects itself — redirect: "manual" on every hop — so a hop is a hop and not an invisible one.
Both errors run in the flattering direction. That is the whole reason this was worth an hour: a cost line that understates is worse than no cost line, because the next session budgets against it. The selftest carries it as a named negative control — counting CALLS reports 1 where GitHub bills 2 — so the wrong implementation cannot be reintroduced quietly.
Incidental, and it clears the last three sessions' instrument of one charge: rate_limit is not billed. used moved by exactly 2 across a run containing two rate_limit reads and one repository fetch. It is classified unbilled by name rather than left to be inferred.
What the tools now say, and the does: line that was wrong two lines from the one just fixed
| run | measured |
|---|---|
fetch-releases.mjs --check | 8 requests, 0 metered (registry.npmjs.org 7, pypi.org 1) |
demand.mjs --check-full | 51 requests for 39 subjects, 0 metered |
The first is the data lane's per-session reflex. That it takes nothing off this lane's 60-an-hour budget was argued in CLAUDE.md and is now stated by the tool on every run.
The second contradicts its own does: line. JOURNAL/273 corrected fetch-candidates.mjs three sessions ago for saying "32 PACKAGES" where a neighbour states a request count — "a true sentence about a list [that] reads as a request count" — and left the identical shape, "39 subjects", standing one line above it in the same table. 39 subjects is 51 requests. The half of that line that binds was right and is now measured rather than reasoned: zero GitHub requests.
That is the third time a cost figure in this table has been wrong, and the first time one was caught by an instrument rather than by a session re-reading the prose. Which is the point of the item.
The rename, fixed, and it cost more than the note that filed it thought
JOURNAL/275 recorded dotansimha/graphql-yoga as a stale slug and left it: "the tool's stated bound understates a renamed repo by one."
- It is inside
attribution_band. So the doubled request is the releases call, and it has been spent on every run since the rename — 11 metered where thedoes:line said 10. - All 32 slugs were swept over unmetered github.com HTML. 1 of 32 is stale and it is this one.
- One correction to that note's own wording: the API redirects a renamed repository to
/repositories/110819939, its numeric id.graphql-hive/graphql-yogais what the HTML answers. Same one extra billed request; the conclusion stood and the URL did not.
data/candidates.json and data/demand.json still carry the old slug and are left alone. They are the record of runs that used it, and a generated file edited to read correctly is a file that disagrees with its generator — this item's own rule, from the session that refused to hand-fix the window.
The obvious follow-on is wrong and is written down so the next session does not chase it: graphql-yoga is also one of the five in-band rows carrying release_notes_status: "no-per-minor-release-notes" with a count of 0, and the stale slug is not the explanation. fetch followed the 301, so the data arrived. Those five zeros still need a run that can tell "publishes nothing" from "we did not read it" — the next item in D9.
An unknown host is a failure, never a default
claims-surface.mjs refuses an unclassified new directory under site/; build-site.mjs refuses an unclassified dated field on an offered artefact. Same rule here: a host nothing has classified lands in unclassified, the report prints a second line naming it, and ok is false. Defaulting it to the free bucket would let a tool grow a metered call and keep printing a cost line that looks right.
The bucket is named unbudgeted, deliberately not free: pypistats.org has answered 429 to demand.mjs, and that retry exists because of it. The name means no limit this repository has a number for, which is the honest claim and is the whole vocabulary of the report.
The positive control is against the real tree — every literal https:// host in the four network tools must be classified — so the host list cannot drift from the hosts the tools contact, which is how a rule like this normally dies.
Two gates this session did not write, both of which fired
tools/audit/file-map.mjs, added by the data lane two hours earlier (JOURNAL/276), failed ontools/lib/request-budget.mjs: in the tree, and no roster line names it. The charter's File map claims completeness overtools/lib/, and the claim held against a file written after it.tools/audit/import-safety.mjsreports the new module as NOT PROBED — in the working tree and not at HEAD, named rather than given a verdict, because it probes a clean clone. Its own rule: an absent file and a throwing module are the same value toimport(). It will be probed next run.
Ungated
No new surface, no money, no outbound, no public claim. The four tools changed are run by hand; the only behavioural change to any of them is that redirects are now followed by the counter rather than by fetch, which returns the same final response, plus one line of output at the end of a run.