docs(adr): migrate rotted ADR line anchors to resolver-gated symbol anchors (#13556) - #14277
Conversation
…rs (#13556) The #13556 census enumerated every line anchor in `docs/adr/**` — 343 across 35 of the 134 records, plus 52 continuation anchors — and resolved each against main. Excluding 4 HISTORICAL and 2 EXTERNAL, 243 of 337 live anchors were broken: 72.1%, declared as a one-way LOWER bound. Rot tracked target-file CHURN rather than record age: the four hottest targets were 100% broken across 50 anchors. A rotted line anchor does not fail — it silently points a reader at unrelated code inside the document whose purpose is to be a durable record. Maintainer ruling 2026-09-01 (总监批 #27), option A: migrate the corpus to symbol anchors behind a resolver gate that reds on an absent symbol, in ONE migration with no transition period, sharing a single resolver with #13788. - `scripts/symbol-anchors.mjs` — the SHARED core: grammar, extractor and the resolution rule. A corpus is a `defineCorpus` registration, so #13788 joins by registering rather than by forking the resolver. - `scripts/check-adr-symbol-anchors.mjs` — the `docs/adr/**` registration. - 469 occurrences rewritten across 35 records: 188 became symbol anchors (173 resolved at a declaration site, 15 as a quoted data identifier), 211 dropped to file level, 15 to cross-repo file level, 10 dead paths lost their number, 24 duplicate or orphaned continuations were removed, and 6 HISTORICAL / EXTERNAL anchors carry an inline exemption marker. Resolution is deliberately stricter than the census: comments are stripped before matching, so a symbol named only in a comment does not resolve, and a token that merely repeats the anchor's own path is refused. That strictness is what confirms 72.1% was a floor — `object.zod.ts` was credited with `stateMachines` by the census, and the only occurrence today is a comment saying that map no longer exists. Nine spellings of a line reference are recognised, three of which the census's own extractor could not see: comma and slash lists, the trailing `+` form, bare anchors in running prose, fenced comment headers, and the tilde form. ⛔ No ADR's decision content changes; only anchor form does. ADR-0113's Context row is known to state the inverse of today's mechanism — carded as #14193, deliberately NOT repaired here, with a note in the record pointing at it. Refs #13556 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mszp61SetNhQfVGvWh9bNn
`check-step-collectors --self-test` was red on this branch:
• lint.yml "ADR symbol anchors resolve (no line numbers survive)":
all green => every command runs (3/2)
The counts are (executed / collected), so three ran where two were collected —
not the reverse. That gate drives the real `run:` block under `bash -e` with a
STUB planted at each collected command's SCRIPT PATH, then holds "all green =>
every command runs" as an equality. The step ended with a bare
`node scripts/check-adr-symbol-anchors.mjs` after the collector's exit guard,
which re-invokes the very stub planted for that script's `--self-test` entry —
a third execution against two collected commands.
Split into two steps: the collector carries the two self-tests and nothing
else, and the production sweep is its own step. That is also the shape both
existing collectors in this file already have, and it restores the property
the split was for — the sweep now runs on its own footing instead of behind
the self-tests' `exit 1`.
⛔ No scope change: `docs/adr/**`, the resolver and its corpus registration are
untouched. Both invocations the gate's own `--self-test` pins in lint.yml are
still present, so the wiring assertion still holds.
Refs #13556
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mszp61SetNhQfVGvWh9bNn
|
Maintainer ruling, 2026-09-03, given in chat to the director seat (session_01WXyGTWPbbreqXow7Z2pZCk) on decision batch 1, item 1 (this PR, option A: merge as-is, including the implementer's floor reading of the 172 bare-filename anchors — anchors that resolve to a symbol are symbol-anchored). Verbatim: 「以上都同意,你帮我合并pr」. Executing on that instruction: marking ready and merging directly (squash) from the maintainer's own account. No approving review is submitted by any seat; under the governed-surface regime the human merge is the review record (see the header of Of the PR's three open questions, only the 61 stale full paths outside the 35 records needed a maintainer answer; none was given in the batch reply, so the director seat records it as a Generated by Claude Code |
Fixes #13556
⛔ DRAFT, and it stays a draft.
docs/adr/**is a governed surface: a human merges this. Never marked ready, never auto-merge.The ruling this implements (verbatim)
Maintainer 2026-09-01, 逐字「同意」, 总监批 #27 — quoted from the director comment on the card:
turso-driver.ts已存在)同扫」docs/adr/**draft + 人工合并;72.1% 是单向误差下界的申报原样入册」Premise re-verified before implementing. The census reproduces exactly on today's
main: 376 raw tokens → 343 distinct anchors across 35 records, matching the census's own per-ADR counts. Nothing had been migrated.The anchor grammar
Documented once in
scripts/symbol-anchors.mjs's header (ANCHOR_GRAMMARis that text, so a failure message cannot drift from the spec):It satisfies the three properties the form had to have: (a) mechanically verifiable —
#symbolis checked against declaration sites, so an absent symbol is a loud red; (b) survives line churn — nothing encodes a position, so an unrelated edit above the target cannot rot it; (c) reads naturally —#fragmentis the syntax a reader already knows from URLs, and it survives copy-paste into a GitHub link. Markdown links keep their link; only the rotted#L<n>fragment is dropped, socheck-adr-linksstill cross-checks them.The resolver's acceptance semantics — what "resolves" actually means
The census's caveat is binding and is why this is not
includes(). Comments are stripped from the target before anything is matched, and a token that merely repeats the anchor's own path is refused outright (the census states this rule for its own classifier: otherwise every anchor "resolves" on the wordregistry/permission/metadata). Two acceptance classes, reported separately and never merged:declarationfunction/class/interface/type/enum/namespace, aconst/let/varbinding, a member or object-literal key at the start of a line (name:,'name':,name(,get name(), a named re-export; for markdown a heading (text or slug); for JSON/YAML a keyliteral'sys_metadata'). Exists because much of this platform's vocabulary is DATA identifiers — object and field API names — declared as strings with no binding site. Whole-token only, never a substringliteralis deliberately weaker and the migration prefersdeclarationwherever one exists.⭐ This confirms the ruling's point 5 — 72.1% was a floor, and the direction is one-way. The census's mechanical test accepted a symbol appearing anywhere in the cited range, including inside a comment. Worked example found while migrating: the census credited
object.zod.tswithstateMachines; on today's tree the only occurrence is a comment saying that map no longer exists. Under this resolver it correctly does not resolve, and the anchor drops to file level.What the gate does NOT claim. It verifies an anchor points at something real. It cannot verify the sentence is true about that code — no static check can. That is a separate defect class with its own card (see the carve-outs).
One scope boundary, stated rather than hidden. Bare file-level path mentions are not judged in this corpus (
checkBarePaths: false). Measured, not assumed: judging every bare path indocs/adr/**yields 1,056 findings across all 134 records, 616 distinct paths — abbreviated spellings, sibling-repo paths, and files that moved years ago. A real defect class, but not the line-anchor rot #13556 measured, and folding it in would bury this gate's signal under a corpus-wide cleanup nobody ruled on. It is a per-corpus flag, so a corpus with uniform citations (#13788) can switch it on.Per-class counts, migrated vs the census
469 occurrences rewritten across 35 records.
declaration, 15literalobjectui:prefix, target NAMED (see below)<!-- anchor-exempt: … -->Reconciliation with the census's 343 + 52 = 395. Non-continuation occurrences came to 376 raw — exactly the census's 376 — deduping to 340 under my key against the census's 343 (dedupe-key nuance: one comma-list occurrence covers what the census counted as separate rows). Continuations came to 78, not 52, and the tilde form added 15 more. The real surface was larger than 395, because three spellings the census's extractor could not see were found:
registry.ts:13,346-389,errors.zod.ts:29-39,147-152,engine.ts:2956/2991~`326`, a bare backticked number with no path at all; ADR-0056 alone held 13Nine spellings in total are now recognised, each provoked by name in the resolver's self-test.
Per-class dispositions from ruling point 3:
objectuicheckout is in tree, so they are downgraded to file-level anchors that NAME the cross-repo target (objectui:packages/types/src/layout.ts). The resolver verifies them only when$OBJECTUI_CHECKOUTis set; without it they are reported, never red — a check that reddens on the healthy case is the permanently-red gate this repo retired. (15 occurrences of the 11 distinct anchors.)24 continuations removed. A continuation landing on the same
(file, symbol)as its parent says nothing new — it was a different line number in the same function, and once the position is gone so is the distinction; keeping them produced#installPackage/#installPackage/#installPackage. It can also be wrong: ADR-0119 reads "implemented by driver-sql (…), driver-memory (:595+), and driver-mongodb (:545+)", where inheriting the preceding path would claim driver-memory's method lives insql-driver.ts. The prose already names the right component; the anchor must not contradict it.Negative control (evidence)
Run on the migrated tree, mutation reverted before commit —
git statusclean, verified.1 — symbol mutated to a nonsense name (
RESERVED_NAMESPACES→RESERVED_NAMESPACEZZZin ADR-0029):2 — a line anchor re-introduced (the migration's permanence, ruling point 2):
Both restored; gate green again,
git status --porcelainempty.20-anchor spot audit
Deterministic sample (seed 13556) over the migrated symbol anchors, each read as ADR sentence vs the newly anchored declaration site. 19 of 20 point at the mechanism the sentence names.
stack.zod.ts#validateSingleAppfunction validateSingleApp(sharing-service.ts#OWNER_FIELDconst OWNER_FIELD = 'owner_id'sharing-service.ts#buildReadFilterasync buildReadFilter(ai-service.ts#ToolExecutionContextexport interface ToolExecutionContext {security-plugin.ts#rlsFilterconst rlsFilter = await this.computeRlsFilter(sql-driver.ts#formatInputobject.zod.ts#managedBymanagedBy: z.enum([…])protocol.ts#restoreArtifactRegistryViewprivate async restoreArtifactRegistryView(registry.ts#mergeObjectDefinitionsfunction mergeObjectDefinitions(setup-nav.contributions.ts#group_people_orggroup: 'group_people_org',(literal)team-graph.ts#expandRoleUsersasync expandRoleUsers(roleName, organizationId?)execution-context.zod.ts#ExecutionContextexport type ExecutionContext =security-plugin.ts#permissionsfield.zod.ts#requiredWhenrequiredWhen: ExpressionInputSchema…registry.ts#RESERVED_NAMESPACESexport const RESERVED_NAMESPACES = new Set([…])metadata-plugin.zod.ts#DEFAULT_METADATA_TYPE_REGISTRYexport const DEFAULT_METADATA_TYPE_REGISTRY:field.zod.ts#LocationCoordinatesSchemaexport const LocationCoordinatesSchema = lazySchema(security-plugin.ts#getReadFiltergetReadFilter: (object, context?) =>security-plugin.ts#computeRlsFiltercomputeRlsFilter: (sets, o, engineOp, c) =>registry.ts#unregisterObjectsByPackageunregisterObjectsByPackage(packageId, force)The audit changed the output, which is the point of running it. An earlier pass had picked
#opCtx(a bare function parameter),#automationfordomains/automation.ts(a token straight off the anchor's own path — the census's named trap), and#permissions/#description/#properties(too generic to locate anything). The path-derived-token rule and a measured weak-token list were added, and every one of those was re-picked or dropped to file level; rows 5 and 19 above are the repaired#opCtxcase.Row 13 is the residual:
security-plugin.ts#permissionsresolved only to a localconst permissionsinside an unrelated function. The sentence is locatingbootstrapDeclaredRoles, the "exact sibling" it names — and that symbol is not in that file (the census's DEAD PATH verdict onbootstrap-declared-roles.tssays the same from the other side). Repaired to a file-level anchor; ⛔ the sentence itself is untouched.The #13788 reuse seam
Ruling: 「共享同一个 resolver,⛔ 不造第二套」. The split is structural, not a convention:
scripts/symbol-anchors.mjs— grammar, extractor, resolution rule,defineCorpus,sweepCorpus. Sweeps nothing on its own and knows about no corpus.scripts/check-adr-symbol-anchors.mjs— adefineCorpus({ id, label, docRoots, docPattern, crossRepos, checkBarePaths })call plus a population declaration and an exit contract. 241 lines, almost all of it header.#13788 joins by adding one registration for the platform-checklist corpus. The knobs it will need already exist and are exercised:
docPattern(its corpus is.mdx),crossRepos, andcheckBarePaths(its citations are uniform, so it can turn the bare-path check on and get it for free). Why one resolver, mechanically: the expensive part is the resolution rule, and two copies drift silently — each gate stays green on its own corpus while meaning something different by "resolves". One implementation, one self-test.scripts/check-adr-anchors.mjsalready exists and is an unrelated gate (code → ADR citations, the #3723 direction). This is the opposite direction — ADR → code — hencecheck-adr-symbol-anchors.Wiring — the gate RUNS
Registered the way sibling gates are, following
check-system-context-census:.github/workflows/lint.yml— a step in the docs-guard job, invoked asnodeper the file's own GATE INVOCATION IDIOM. The gate's--self-testreads lint.yml and fails if either invocation line is missing, so silently rewiring it reddens it.package.json—check:adr-symbol-anchors, matching the--self-test && <gate>family shape.run_self_testcollector, not a bare sequence — underbash -ethe first non-zero exit aborts the step, so a chained second self-test is neither green nor red ([finding] lint.yml's shallow-history step runs three self-tests underbash -e— a failure in the first silently prevents the other two from running at all #10814).check-step-collectorscaught this and now passes.Checks run
pnpm lint✅ ·symbol-anchors --self-test✅ ·check-adr-symbol-anchors --self-test✅ (every finding class provoked on a synthetic corpus, healthy anchors silent, population live, wiring pinned) · gate on the migrated tree ✅ 1,854 anchors across 134 records, 0 line anchors surviveDerived from
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, plus the two mandatory for anyscripts/**touch:check-ratchet-remedy-authority✅ ·check:declared-population-live✅ ·check-watch-hint-literal✅ ·check-step-collectors✅ ·check-self-test-wired✅ ·check-adr-links✅ ·check-adr-anchors✅ ·check-doc-anchors✅ ·check-doc-frontmatter✅ ·check-docs-single-h1✅ ·check-ci-filter-parity✅ ·check-aggregator-roster✅ ·check-required-contexts✅ ·check-adr-0087-registration✅ ·check-empty-changeset✅ ·check-changeset-no-major✅ ·check-self-test-workflow-commands✅ ·check-workflow-status-functions✅ ·check-keyed-text-bounds✅Two gates legitimately failed against the new code and were fixed, not worked around:
check-declared-population-live(the shared core declared a path-shaped literal in its grammar examples that named no tracked file — the examples are now<dir>/<file>.tsplaceholders and the module carries adispatch-gates: no-path-populationmarker, since a corpus supplies its own roots) andcheck-watch-hint-literal(the declaration was spelledWATCH_HINTS, which is not on the roster — nowROOT_DIR_WATCH_HINTS, pinned in the self-test against the roots the corpus actually sweeps).Changeset route
skip-changeset, by the workflow's own prescription: lint.yml states that "this PR edits a CI-internal script" is the textbookskip-changesetcase — such a PR releases nothing. This PR isdocs/adr/**+scripts/**+ CI wiring and releases nothing user-facing. Label applied.Carve-outs held OUT of this PR (ruling point 4)
NOT NULLnow keys offstorage.notNull, notfield.required; the Context row states the inverse of the shipped mechanism. Already carded as ADR-0113's Context row now states the INVERSE of the shipped mechanism — the NOT NULL constraint keys off storage.notNull at sql-driver.ts:15565, not field.required #14193. ⛔ Not repaired here. The record now carries a note anchored at file level pointing at that card, because the row describes the PRE-decision state and rewriting it inside an anchor migration would edit a decision record's substance under cover of a formatting change.@objectstack/driver-tursohas since been migrated back into this repo… The decision recorded here is unaffected." That is the annotation the ruling asked for, already present. Evidence quoted rather than a duplicate card filed.Open questions for the reviewer
packages/runtime/src/kernel-manager.tsin ADR-0004) — real rot of a different class, found while measuring the bare-path option. Recorded here, ⛔ not fixed, and not carded pending direction on whether it wants its own card.object.stateMachinesrow now describes a map whose only trace inobject.zod.tsis a comment saying it no longer exists. Plausibly that ADR's own decision landing rather than a defect, so no card — flagging it rather than deciding it.🤖 Generated with Claude Code
https://claude.ai/code/session_01Mszp61SetNhQfVGvWh9bNn
Generated by Claude Code