Skip to content

fix(docs-audit): retire the lead-anchor widening family for a key-position allowlist (#11717) - #11736

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-11717-lead-key-allowlist
Aug 24, 2026
Merged

fix(docs-audit): retire the lead-anchor widening family for a key-position allowlist (#11717)#11736
os-steve merged 3 commits into
mainfrom
claude/issue-11717-lead-key-allowlist

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#11717

declLead in scripts/docs-audit/affected-docs.mjs anchored the ledger route: / client: key with a blocklist — first a word boundary, then #11630's lookbehind over [\w$.]. Every blocklist names a class it excludes, so every one of them has a residue, and this key's residue was worked through one card at a time: #11494 the colon run, #11542 the word boundary (subroute:), #11630$ and ., #11711 queued for Unicode because \w is ASCII-only. Each card was small, provably free and honestly priced, and each named the next residue as a pin for the next card to flip. That is a good discipline for an open-ended defect and the wrong one for a bounded one.

The anchor is inverted. It no longer enumerates what may not precede the key; it names the positions where an object-literal property key may begin — start of input, whitespace, {, , — and rejects everything else. It stays spelled once, in declLead, with the \s*:\s* run untouched. It is spelled verbatim in declLead's docblock and in the --self-test boundary comment, and deliberately not here: GitHub's body sanitizer strips the less-than + bang digraph out of an issue or PR body — inside code spans and fenced blocks alike — and silently leaves a different, valid-looking regex behind. Measured twice on #11634, honoured by #11710.

⛔ And it needs no u flag, which is the concrete cost the blocklist route was carrying. Closing the Unicode residue as a character class meant \p{L} under u, which changes escape semantics for every source these leads are composed with at the eight call sites. An allowlist of ASCII positions needs none, so #11711 is subsumed at no cost.

This branch is merged with main, and it carries #11710 forward rather than replacing it

An earlier revision of this PR was cut from e170b0ae5, which predates #11710 in graph order — merge-queue commit dates are not monotonic, and I wrongly reported that as "#11710 is not merged", which was a claim about main made from a reading of my own base. origin/main has been merged in (no rebase), and the conflict in this one file was resolved from main's version, so:

The premise was attacked before it was implemented

The card asked for its own ruling to be attacked. Both halves hold:

  • The legal key set really is exactly two. All eight new RegExp(declLead(…)) call sites pass string literals(route|client) ×3, route ×3, client ×1, (?:route|client) ×1. Nothing is parameterised, computed or read from config.
  • All eight can take the same allowlist, but the two scans that read a slice rather than a whole string were checked individually, because a lookbehind only sees what is inside the string it is given. nextRouteRe searches rest.slice(1) and windowClientRe matches window, which begins at the current row's own route. In both, any later key's preceding character is necessarily inside the slice, so neither can lose context. The other six read a whole string.

One correction to the card's framing, which the PM has accepted. The card said an allowlist closes "$ / . / Unicode / - / every future escapee at once". It closes every spelling that wears a character and leaves the plainest one. cond ? route : 'GET /api/v1/x' — a bare key in expression position preceded by whitespace — is byte-for-byte what a property key looks like, so the allowlist admits it, correctly by its own rule. #11630 named this same class as the one "no lookbehind can reach", and that is exactly why it left - admitted: a-route really is the whole token route. So this is a boundary, not the next link in the chain — closing it needs the colon's enclosing expression, not its left neighbour, which is a parser question. It is pinned as deliberately unmoved, in its plainest spelling, so the class is pinned rather than approximated by one of its spellings.

It admits strictly LESS — swept against the anchor it actually replaces

admits
word boundary (two cards back)12225
#11630's lookbehind — the merged base12223
this branch's allowlist25
admitted by this branch but not by the merged base0
admitted by this branch but not by the word boundary0

Swept over code points 0..0x2FFF, start-of-input checked separately (admitted by all three). All of this is pinned in --self-test. The sweep now compares against both predecessors on purpose: a sweep that only ever compares with the oldest spelling stops being evidence the moment two cards land in a row — which is exactly what happened here.

TWO populations priced, both at ROW IDENTITY, both re-derived on the merged base

Counters agreeing is consistent with two rows swapping places, and this ledger's own arithmetic is what hides the defect — so both are priced by hashing the row list and taking the set difference in both directions.

Population 1 — the ledger rows (--bridge-coverage)

merged base 945ffbea8ablatedthis branch fcd296bbd
rowsParsed / routesDeclared269 of 269269 of 269269 of 269
clientRows / clientsDeclared222 of 222222 of 222222 of 222
reachable / unreachable45 / 17745 / 17745 / 177
leadsOutsideCode000
brokenScan0 (exit 0)0 (exit 0)0 (exit 0)

Which rows move: none.--bridge-coverage --json carries all 177 unreachableRows by {file, route, client}, and git hash-object gives d04a5cedfb613370e5b46ac4725db1d941e5dc88 on the merged base and on this branch. Set difference over {file, route, client} is 0 in each direction (177 vs 177). The human-rendered output diffs empty.

Population 2 — declarationsIn, which moves too

Measured by slicing declarationsIn and its dependency closure out of the file and running the file's own bodies over the seven live ledgers. The closure is walked from declarationsIn, never hand-listed, and the walk follows imported edges as well — load-bearing twice over: blank and scanSource live in ../js-comment-mask.mjs, and on the merged base the walk picked up unreadableIn, a function that did not exist at the earlier base. A hand-listed closure would have silently measured the wrong thing.

closure WALKED from declarationsIn:
local [codeOnly, typeDeclRegions, declLead, unreadableIn, declinedIn, declarationsIn]
imported [scanSource, blank]
treeledgersdeclarationsgit hash-object
merged base 945ffbea87491766641aeefd8fff085a39a087abc24e4fe2ecfff
this branch fcd296bbd7491766641aeefd8fff085a39a087abc24e4fe2ecfff

Set difference 0 in each direction. The hash is this harness's own row shape (it carries a file field), so it is compared only against itself across the two trees and is not offered as matching any figure from #11710.

Positive control — run BEFORE either zero was believed

A synthetic ledger carrying the escapees plus the residue. On the merged base the harness reports 5 declarations and on this branch 3, and the diff names exactly the two this card adds over #11710:

< "text": "route: 'GET /api/v1/dash'"
< "text": "route: 'GET /api/v1/unicode'"

$ and . are absent from both sides because #11710 already closed them — which is itself the control working. The three survivors are the genuine route:, its genuine client:, and the bare-expression residue: non-vacuity in both directions from one fixture. The preceding-character tally carries its own control the same way, and it failed first on a wrong expectation spelling — fixed in the expectation, not in the instrument.

Why both are free on today's tree — re-derived at the merged base

Across the seven live ledgers there are 499route: / client: lead occurrences and every one is preceded by a space: 0 by $, 0 by ., 0 by -, 0 by {, 0 by ,, 0 by any character outside the allowlist at all. All 499 survive.

The pins are FLIPPED and ADDED, never deleted

pinned assertionbefore (merged base)after
a --prefixed lead still mints a row — deliberately outside the set2 rowsflipped → mints NO row, 1 row
a UNICODE-prefixed lead still mints a phantom row — residue, deliberately unmoved2 rowsflipped → mints NO row, 1 row
the run between a colon and its value is spelled ONCEthe lookbehind spellingupdated to the allowlist spelling
every key spelling a call site passes comes back ANCHOREDthree lookbehind spellingsupdated to the three new spellings
the anchor only ever REMOVES — admits nothing \b did not0kept (still 0)
and the characters it moves are exactly $ and .2superseded by the three-way sweep margin

Added: a second sweep leg pinning that the allowlist admits 0 that the lookbehind it replaces did not, the three-way admit counts, and the bare-expression residue in its plainest spelling with its silence pinned alongside.

--self-test: 416 cases, merged base 413+3 net, on a diff that flips two assertions and adds three.

Reverse verification (ablation) — mutation proven on disk, restore under a trap

The one line the fix moved was reverted to the merged base's spelling#11630's lookbehind, not the word boundary two cards back — under trap … EXIT INT TERM armed before the mutation. affected-docs.mjs is run directly from source by node: there is no dist/ in its path, so no rebuild leg is involved and none is claimed. Mutation confirmed on disk by counting the injected and the removed text separately, anchored on the exact text meant to move, plus hashes:

CLEAN_HASH=60ab693ca688db1fc29b5937bd7a9b396166f806
ON DISK: injected=1 (want 1) removed=0 (want 0) # declLead anchor
MUTATED_HASH=3c7ec562ca54b3437ded2783e05dfeca845133c5
RESTORED_HASH=60ab693ca688db1fc29b5937bd7a9b396166f806 (byte-identical)

Direction predicted before running: turn red on exactly this card's delta over #11710, and on nothing else. Observed exactly that — ABLATED_SELFTEST_EXIT=1, 5 checks fail, each got value the before state:

✗ "a `-`-prefixed lead mints NO row — the pin #11630 left is FLIPPED" expected 1, got 2
✗ "a UNICODE-prefixed lead mints NO row — #11711 subsumed" expected 1, got 2
✗ "the run between a colon and its value is spelled ONCE" expected 1, got 0
✗ "every key spelling a call site passes comes back ANCHORED" got the three lookbehind spellings
✗ "and the allowlist is the far smaller set" expected "12225 / 12223 / 25", got "12225 / 12223 / 12223"

The controls that stayed green are the point, not an omission — a red one would mean the fixture, not the fix, was doing the work:

Non-vacuity in the other direction is carried by the same fixtures: the genuine route: 'GET /api/v1/meta' is still read, still carries meta.getTypes, and the 269 live rows are unchanged.

Verification

Merged base origin/main945ffbea8; measured and re-measured on final head fcd296bbd, with a clean tree at that commit.

Gate union derived, not recalled — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths; the script takes the change set from the merge base itself, and after the merge it correctly reduces to this branch's one path), 9 families, plus check:nul-bytes and the self-test directly. Exit codes captured before any pipe; each verdict below is the line the gate itself printed.

gateexitits own verdict line
pnpm check:agent-test-spelling0✓ check-agent-test-spelling: 0 violations — 351 file(s) · 3319 bare -- token(s)…
pnpm check:cross-package-test-inputs0OK: 16 package(s) read outside themselves, all declared…
pnpm check:docs-audit-scope0✓ release-owned pages are in scope and read-only: 9 page(s)…
pnpm check:entry-guard0✓ check:entry-guard: 143 scripts/ file(s) — every entry guard goes through invoked-as.mjs
pnpm check:parse-guard0✓ check:parse-guard: 142 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-governed-merges0✓ check-governed-merges --self-test: 129 assertions
pnpm check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 135/168 --filter occurrence(s)… resolve
node scripts/check-cross-package-test-inputs.mjs0OK: 16 package(s) read outside themselves, all declared…
node scripts/docs-audit/check-affected-docs.mjs0✓ affected-docs self-test: 416 cases pass.
pnpm check:nul-bytes0✓ check-nul-bytes --self-test: 75 assertions over a temp git repo
affected-docs.mjs --self-test0✓ affected-docs self-test: 416 cases pass. (merged base: 413)

Lint — the full repo-wide run, not a narrowing.pnpm lint (eslint . --no-inline-config) was run in full on fcd296bbd under the shared verify lock: exit 0, no findings, VERDICT command-exit 0 · held the lock 56s · waited 51s. No narrowing is claimed and none is needed.

No changeset — checked against the actual rule, not inherited.changeset-check in .github/workflows/pr-automation.yml has no path exemption; its only two exemptions are the skip-changeset label and the Changesets release PR. Verified that this branch's own diff publishes nothing: the root package is private: true with no files field, scripts/ matches none of the pnpm-workspace.yaml globs, and it has no package.json of its own. skip-changeset applied and read back. (The .changeset/*.md files visible in the merge commits are main's, arriving through the merge, not this card's.)


Generated by Claude Code


Generated by Claude Code

…ition allowlist
`declLead` anchored the ledger `route:`/`client:` key with a word boundary,
which fails only against a preceding WORD character. Four cards each shrank one
more character class out of the false-positive set and named the next residue,
which is the wrong discipline for a bounded defect: the anchor admitted 12225
of the code points in 0..0x2FFF and the family was arguing about a handful at
the edge of that set.
The anchor is inverted. It no longer enumerates what may not precede the key;
it names the positions where an object-literal property key may begin — start
of input, whitespace, `{`, `,` — and rejects everything else. That closes `$`,
`.`, `-` and Unicode identifier characters in one move, needs no `u` flag, and
keeps the anchor spelled once in `declLead` with the `\s*:\s*` run untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
Resolved scripts/docs-audit/affected-docs.mjs in favour of the key-position
allowlist, taking main's version as the resolution base so #11710 (#11630) is
carried forward rather than reverted: all of its scan-by-scan `$route:` and
member-access fixtures are kept, and its two boundary pins (`-` and Unicode,
both left deliberately unmoved there) are FLIPPED rather than duplicated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

@os-steveClaude

Copy link
Copy Markdown
Collaborator

ACCEPT at fcd296bbd. Verified by content.

5f124a219 (#11710) IS an ancestor of this branch ← not reverted
declLead → String.raw`(?<[^\s{,])${keys}\s*:\s*` ← allowlist, spelled once
#11710's fixtures $route 20→21 · obj.route 3=3 ← carried forward
regex `u` flag on the lead scans 0 → 0 ← deliberately avoided

The correction, and the sentence worth keeping

a base reading licenses claims about my base, never about main.

That is the whole lesson and you wrote it better than I did. The merge-queue detail is the part that makes it a trap rather than carelessness: e170b0ae5 has a later wall-clock timestamp and an earlier graph position, so a reasonable person sorting by date concludes the opposite of the truth. Superseding the earlier report in place rather than appending a correction was also right — a report that has to be read in order to be correct is a report that will be misread.

The merge did what it had to

Resolving from main's version and re-applying on top is the only ordering that cannot silently revert #11710, and you proved it the right way: every one of #11710's $route: and .route: fixtures stayed green under your ablation, and a red one there would have meant the merge ate it. That is a control chosen for the failure you were actually exposed to, not a generic one.

Pin accounting checked at check() level rather than by grep count — ⚠️ my first probe counted prose and pins together and read 4→5, which is the "counted the wrong population" error I have been flagging in others all shift. At check level: the Unicode deliberately-unmoved pin is gone (flipped, as claimed), the #10794 PROSE pin is untouched (correctly — it is another card's), and a new boundary pin appears for the bare-expression residue. Nothing deleted without replacement.

Three things I did not ask for and would not have thought to

The closure walk earned itself twice. On the merged base it picked up unreadableIn — a function that did not exist at your earlier base. A hand-listed closure would have silently measured the wrong thing, and produced a byte-identical hash that meant nothing. That is the difference between an instrument and a ritual.

The sweep went three-way, for a stated reason.12225 (word boundary) / 12223 (#11710's lookbehind) / 25 (allowlist), with the invariant taken against the anchor actually replaced rather than the one two cards back:

a sweep that only compares with the oldest spelling stops being evidence when two cards land in a row

⭐ That is a general finding about this whole family's method, not a detail of this PR. Two cards landing in sequence is exactly when a carried-forward baseline goes quietly stale.

The positive control read correctly through a changed world. On the merged base the synthetic ledger reports 5 vs 3 with $ and .absent from both sides — because #11710 already closed them. You noted that this absence is the control working rather than treating it as a broken probe. That is the reading I would most expect a careful run to get wrong.

The boundary, accepted and correctly framed

cond ? route : 'GET /x' stays admitted, correctly, by the allowlist's own rule — it is byte-for-byte a property key. My card overstated this ("closes every future escapee"); the true statement is the allowlist closes every spelling that wears a character and leaves the plainest one, and closing that one is a parser question, not another anchor. Pinned deliberately unmoved in its plainest spelling, which is the right treatment for a boundary that is not a residue.

Avoiding the u flag with the cost stated (\p{L} under u changes escape semantics for every source these leads compose with at eight call sites) is the concrete argument the blocklist route never had.

#11711 — I will close it by hand as subsumed on merge. Writing no closing keyword for it was right: a closing keyword ignores any negation around it, so "does not close #11711" is unsafe to write.

Flipping ready; arming once every check is green. ⚠️ Ready ≠ armed — this seat has been caught twice today arming-adjacent to a red, both after flipping ready.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 24, 2026 14:58
@os-steve
os-steve added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit e30a7e5Aug 24, 2026
31 of 33 checks passed
@os-steve
os-steve deleted the claude/issue-11717-lead-key-allowlist branch August 24, 2026 15:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs-audit: retire the lead-anchor widening family — move all eight route:/client: scans to a whole-token KEY ALLOWLIST

2 participants

@os-steve@claude