Skip to content

[decision] docs-audit: a data-property anchor is both the noisiest and the most valuable anchor the tool mints — 70 of 402 rows, and no cheap discriminator survives measurement #12824

Description

@claude

Split out of #12730 by the R19 dev seat after measuring it. #12730's stated mechanism (anchors derived per changed FILE) is false and was false when filed — hunk scoping landed in 2e1d82aa1 (#9229) ten days earlier. The observation on that card reproduces exactly; this card is the design question its real cause opens, which the dispatch's Zone 1 reserves for the maintainer because every candidate remedy moves the precision/recall trade in a docs gate.

The finding, in one line

scripts/docs-audit/affected-docs.mjs mints a doc anchor from the most specific declaration enclosing a changed line. When that is a data property (name: / name?: inside a type, interface, or Zod object) the resulting anchor is either the single most on-target anchor available or pure noise — and nothing currently distinguishes the two.

Measurements

Instrument identical throughout: affected-docs.mjs blob 0a4249636a444a3736d9f593db94a65b8853f3aa at PR #12727's head, at 632e862d1, and at today's c4ecf0c49. Replays run at each PR's last commit before its own docs fix, so the PR's correction cannot insert the anchor being measured.

On PR #12727 (the run #12730 was filed from) — reproduced exactly at 25 anchors / 25 hand-written rows:

  • 13 of 25 anchors produced ZERO rows — and those 13 are every name specific to the change (MetaOverlayCacheKey, readMetaOverlayCache, cacheKeyOf, readWriteEpoch, …). Precision on the change's own vocabulary is perfect.
  • 16 of 25 rows came from three data properties of internal cache structs: organizationId and packageId on MetaOverlayCacheKey, expiresAt on MetaOverlayCacheEntry.
  • Both existing guards pass them legitimately: they are code-shaped, and the corpus-share limit is floor(189 * 0.15) = 28 pages while organizationId matches 10. They are not hub terms — they are ordinary field names.
  • Through the bridge they also minted publishItem (sdk) and /:type/:name/publish (route) on a diff touching no state machine — the amplification the :4420 note predicts.

Population — 91 consecutive main commits touching packages/. A variant that prefers the container whenever the winning declaration is a data property:

rows
current 402
variant 330
removed 72 (-17.9%), zero added

Only 10 of 91 runs change at all — surgical, not blunt. Against ground truth (the 46 docs pages those same commits edited): current lists 10, variant lists 10 — zero measured recall loss.

Why that number does not license the change

The ground truth is too weak to license it — it can only detect losses among pages the tool already finds, and it finds 10 of 46. Reading the 72 dropped rows by hand finds true positives the aggregate cannot see:

  • userActions — dropped data-modeling/objects.mdx, the canonical page for that authorable key (a field-table row plus eight further references), on a commit that changed what userActions accepts. Declared in packages/spec/src/data/object.zod.ts — a definition site.
  • schemaMode — dropped data-modeling/drivers.mdx, which documents it at line 152 as "schemaMode — the ADR-0015 ownership mode".

So the same syntactic construct yields the best anchor the tool can mint and the worst one. A false negative here is strictly worse than a false positive (a wrong row costs a reader a minute; a missing row ships a falsified page), which is why this is not a judgement I will make unilaterally.

The discriminators that do NOT work — each disproven, not assumed

  1. Syntactic form (data property vs method). Fails: userActions and expiresAt are both name: in an object/interface.
  2. Declaring package (trust packages/spec only). Fails: schemaMode is authorable but lives in packages/objectql/src/engine.ts.
  3. Property NAME against the authorable-surface registry. Fails, and instructively: expiresAt, organizationId and packageId are authorable property names — on api/Session, cloud/Environment, api/GetMetaItemsRequest. A name-only test keeps all three noisy anchors and gains nothing.

The discriminator that DOES appear to work — and its one blocker

packages/spec/authorable-surface.base.json already keys the surface as container:property (data/Object:userActions, data/Datasource:schemaMode). Qualifying the anchor by its declaring container separates every measured case correctly:

  • Of the 20 distinct containers that minted a dropped data-property anchor, 18 are internal implementation typesMetaOverlayCacheKey, LocalizationCacheEntry, AuthzCachePostureInput, MintScimConnectionCredentialInput, SysScimConnectionBinding, AUTH_MODEL_TO_PROTOCOL, enObjects, … — and 2 are authorable spec types (ObjectSchemaBase, DatasourceDef).
  • Projected: removes 70 of 402 rows (-17.4%) while preserving both demonstrated true positives.

This also explains the largest single case in the population — a SCIM commit dropping 27 rows on managedBy, isSystem, listViews, nameField, pluralLabel, titleFormat …. Those are authorable key names, but the changed lines use them in a system-object literal rather than define them. Use sites move no contract; definition sites do. The container test encodes that distinction; the name test cannot.

⚠️ Blocker, and the reason this is a decision rather than a patch: the TS declaration name is not the spec type name — ObjectSchemaBase is data/Object, DatasourceDef is data/Datasource. No lookup exists today. gen:schema necessarily knows the mapping, so surfacing it is tractable, but it is real work on the spec generator, not a tweak to the docs script. The projection above is hand-classified against the registry, not an end-to-end run — the honest label for it is a projection.

Options

  • A. Status quo. Keep minting data-property anchors. Cost is measured: ~17% of all rows, concentrated so that one run in nine gets badly inflated — and [finding] docs-audit derives anchors per changed FILE, not per changed hunk — on a 20k-line file it named 22 unrelated pages and missed the one the diff actually falsified #12730 shows the inflation lands past the bot's 15-row display cut, so it truncates exactly when signal-to-noise is worst.
  • B. Prefer the container for every data property. -17.9% rows, zero added, zero measured recall loss — but demonstrably drops the userActions and schemaMode rows, which are the best rows the tool produces.
  • C. Publish anchor provenance only. Each row already says which anchor put it there; add which declaration minted the anchor, so a row reads "via organizationId, a field of MetaOverlayCacheKey". Zero recall change, no threshold moved. Does not reduce the row count, so the display-cut problem survives.
  • D. Container-qualified against authorable-surface.base.json. Projected -17.4% with both true positives preserved. Requires the TS-name to spec-name mapping.

Four-axis analysis

Real business need. Real but bounded, and measured rather than asserted: one run in nine is affected; when it hits it dominates (16 of 25 rows, 27 of 76 on the SCIM commit). The consumer is an author deciding which pages to re-read, and #12730 records the concrete failure — a truncated 25-row list that trained the reader to skim while the one falsified page was absent for an unrelated reason. Not speculative surface: this is the eighth precision finding on this machinery (#9331, #10683, #10793, #10794, #11434, #11717, #11802 precede it).

Long-term soundness. D is the only option that answers the question at its source: what makes an anchor discriminating? — currently answered by two syntactic proxies (shape, corpus share) that are properties of the token, never of the relation between token and change. B adds a third syntactic proxy and would join the same queue of point repairs. C is honest but is transparency about a defect rather than its repair. Note #11434's symptom reproduces byte-identical under today's script (6 rows via SqlDriver, types.mdx absent) — that is the container face of this same root; a repair aimed only at the property face leaves it standing.

Making AI-written metadata apps hard to get wrong. This is where D separates sharply. The authorable surface is exactly the surface an AI-authored metadata app writes against, and D makes the docs gate most precise on precisely those keys while shedding noise from internal types an app author never sees. B optimises the aggregate and blunts the gate on userActions/schemaMode — the keys most likely to be got wrong. Consumer-side tolerance is not in play here; nothing is being widened.

Startup scope discipline. Argues against B and against any large rewrite, and it is the reason to be explicit that D costs generator work. If D's mapping is judged too expensive now, C is the cheap honest holding position — it makes the defect legible without pretending to fix it, and does not spend recall to buy a tidier list.

Recommendation

D, with C as the immediate step if D is not funded now. D is the only candidate that survives every measurement, and it reuses a declared source of truth the repo already gates on rather than inventing a fourth proxy. ⛔ Not B — it buys a 17.9% tidier list by paying in false negatives on the authorable keys, which inverts the priority the grading comment on #12730 set out. Not A silently: if neither C nor D is funded, that should be a recorded decision to accept the noise, not a gap.

⛔ I did not touch documentableDeclarationsAt, OVERBROAD_ANCHOR_SHARE, or any threshold. No PR.

Reproduction

git worktree add --detach /tmp/wt 4482c848ab605ee200d1a364e3f1d7b3112c7215
cd /tmp/wt && git checkout c4be8da2f
node scripts/docs-audit/affected-docs.mjs 15bf9e859e56862e6ebe7b5c42404de103362457 --json

Generated by Claude Code

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions