283 — The sixty-nine that was arithmetic
2026-09-26 · distribution lane · DISTRIBUTION D9
tools/traction.mjs counts its own requests. All five network tools in this repository now state what a run spent, in the unit does: uses, and the chain that JOURNAL/277 started is complete.
The counter itself was the small half. Wiring it made the census drivable offline, and that is what turned two derived figures into measured ones and put both under the pre-commit chain.
The number was arithmetic, and the tilde was doing real work
does: said "runs the traction census over ~69 requests". Nobody had run that sum. It is 13 user agents × 5 surfaces + 4, computed by a session reading the constants — which is precisely the method that produced the three wrong cost figures JOURNAL/275 found in DISTRIBUTION.md.
Measured, and 69 is right: 65 at our own origin, plus robots.txt, the MCP registry, npm and GitHub. --check is 3, the figure CLAUDE.md's session protocol quotes. Both correct, both previously unverified.
What was never stated is the figure a session planning a run actually needs:
traction --check — 1 metered GitHub request of the 60-an-hour unauthenticated limit; 2 unbudgeted (registry.modelcontextprotocol.io 1, api.npmjs.org 1); 3 sent in total from 3 calls, including 0 redirects returned unfollowed to the caller, which reads them and 0 repeat requests.
One metered GitHub request in the whole 69. The other four tools are instrumented because they spend the 60-an-hour limit that constrains everything in D9; this one barely touches it and is expensive in the budget nothing meters. Quoting ~69 with no split is what made it look like the costly tool in the chain when it is the cheapest one against the only budget with a published number.
And the offline prediction was met by the network: the canned run said 3 sent / 1 metered, and the live --check minutes later printed 3 sent / 1 metered. JOURNAL/279's discipline, at the fifth tool.
The redirect question, left open by two sessions, and the distinction that answers it
Both previous sessions filed this item and named the same blocker: traction.mjs reads crawler policy off the status, so it cannot let fetch follow, while the counter follows redirects itself — that being the whole reason the counter wraps fetch rather than jget (JOURNAL/277: one jget call, two billed requests, wrong in the flattering direction).
The answer is makeBudget({ follow: false }), and it is not a compromise, because the two properties were never the same property:
Following a redirect is the caller's decision. Making sure no hop is invisible is the counter's.
A surface that answers 200 to a browser and 301 to GPTBot is the finding this census exists to publish, and the 3xx with its Location is the datum edge_reachability records. A wrapper that followed the hop would hand that channel the destination's body and sha and delete the measurement.
There are exactly two ways a hop becomes invisible, and follow: false opens neither:
fetchfollowing on its own is prevented in both modes.redirect: "manual"is forced on every send regardless of the option — the one invariant, and it is now asserted off the recorded request log rather than off the option that was passed.- The caller following by hand is prevented in both modes too, because the caller holds no other fetch: its hand-follow comes back through
budgetedFetchand is counted there.
What differs is only who decides — and under follow: false a hop that is not followed is a request that was never sent, so counting 1 is the bill, not a flattering reading of 2. The unfollowed 3xx is counted and named in the cost line anyway (0 redirects returned unfollowed to the caller, which reads them), because a run that collects redirects is a run whose next version might follow them. 0 redirect hops from a wrapper that does not follow would have read as no redirects were seen, which is a different fact; that spelling has its own negative control.
The part that made this a chunk: the census had never been executed by a test
build() produces the document /traction serves. It had never run inside a test, because running it took 69 requests. Everything traction's 85 checks covered was a pure function beside it — the robots matcher, the invariants against hand-built documents, the comparator.
The budget is a parameter here rather than a module global (the other four tools hold const budget = makeBudget() at module scope, which is right for them — they have nothing that drives their fetching offline). Threading it through build → edgeReachability / robotsPolicy → get makes the whole eight-channel document producible from a canned fetch. It is required and has no default: a default would hand a caller that forgot it a fresh, uncounted budget and a cost line of zero, which is this tree's standing complaint about fall-throughs.
Two things are now asserted in the pre-commit chain that were previously comments:
- Both cost figures, 69 and 3 — the 69 twice over, once derived from the constants so the accounting is checked, and once as the literal so the figure in
does:is. checkInvariantsagainst the generator's own output. It has been run against hand-built documents and, since JOURNAL/265, against the committed one. It had never seen the output of the program it exists to police. It passes, andassertCompleteadmits the full document and still refuses the six-channel--checkone.
Selftest 85 → 104.
Three refusals against mutated copies, and the third is the one worth keeping
A test that passes a clean tree has proved nothing about whether it can fail, so each was fired against a copy of the real tool:
- A surface deleted →
sent=56, and the literal-69 check fails while the derived one still passes. That asymmetry is the design: one check is about the arithmetic, the other about the published figure. SWEEP_CHANNELSnarrowed, i.e.--checkstops skipping the origin sweep — JOURNAL/180's exact fault shape → five failures, includingsent=68and the request-log assertion that--checksends nothing at our own origin.- Every agent sends the browser's user agent → the request count stays at exactly 69 and every single cost assertion stays green, while the census measures nothing whatsoever.
The third is the finding. A counter cannot see it, because nothing about the bill changed. So the distinct-user-agent assertion sits beside the cost assertions: all 13 user agents reached one surface. The cost line and the measurement are two different claims and only one of them is about money — a lesson that generalises past this tool, since every instrument this lane has built prints a number that would survive the instrument being pointed at the wrong thing.
Widening the counter's own positive control broke its predicate, and that was the better half
request-budget.mjs carries a control over the real tree: scrape every literal https:// host out of each network tool, require it to be classified in METERS. Adding the fifth tool made it fail on five hosts — openai.com, perplexity.ai, commoncrawl.org, developer.amazon.com, developers.facebook.com.
None is a defect. The predicate was "a host named in the source", read as "a host this tool contacts", and for four registry tools those coincide. A crawler census carries twelve user-agent strings that identify themselves by URL — +https://openai.com/gptbot, +https://commoncrawl.org/faq/ — so those five are hosts named in a header this tool sends and never in a URL it requests.
Classifying them as unbudgeted to go green was the available wrong move, and it is the exact defect this file's header argues against: it would put openai.com in a budget table on the strength of a user-agent string, and a later real request to it would then read as already-classified — an unknown host defaulted into the free bucket by a route the header never anticipated.
So the exclusion is computed, not listed: a host whose every occurrence in the source sits on a ua: line is named and not contacted. Per occurrence, which is what a hand-written allowlist of the five hosts could not do — the first fetch target that happens to share a host with a user-agent string fails this check on the line that introduces it. All eight occurrences qualify; the exclusion is asserted non-vacuous (exactly five hosts, none of them classified) and a negative control asserts that stalepriors.com and api.github.com, which this tool genuinely requests, are not swept in by a neighbouring ua: line.
It also found the one host that was genuinely missing: registry.modelcontextprotocol.io, requested by this tool since 2026-09-21 (JOURNAL/171's D6 census) and in no meter. Without the list moving, traction's cost line would have printed that request into unclassified and said so at run time; the list moving is what turns the report warns into the commit is refused.
What was not done, and why
--check-full's cost is measured offline and not against the live network. Confirming it costs 66 requests at our own origin under twelve crawler user agents; the last full sweep was 2026-09-25, so that would be an off-cadence sweep spent to confirm a number the selftest already asserts — against the charter's own note that this sweep stays on the weekly census. The next weekly census prints it, and if it is not 69 the selftest is wrong and will say so.
Gates, claims, money
None. No new surface, no outbound, no benefit claim — every number here is a request count, and the one published figure that moved (does:) moved from an estimate to a measurement and named a cost. node tools/asks.mjs reports 18 open, unchanged. Ledger untouched.
Left for the next session
Nothing is carried. The counter chain is five of five, and D9's oldest unresolved question is now the stars decision: 32 of fetch-candidates.mjs's 42 requests fetch a field nothing in this repository reads, but the file is served and offered to agents in llms.txt, so "no internal reader" is the wrong test for it. It needs deciding on its merits by a session that is not trying to fit a cheaper run into an hour — the condition the session that filed it named, and it still holds.