Skip to content

fix(lint): the three ADR-0120 uniqueness rules name the object in the where slot (#9600) - #9908

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-9600-index-rule-config-path
Aug 19, 2026
Merged

fix(lint): the three ADR-0120 uniqueness rules name the object in the where slot (#9600)#9908
os-steve merged 1 commit into
mainfrom
claude/issue-9600-index-rule-config-path

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#9600

Verified at dffca71dea — the union below was run on that exact head.

What was wrong

AuthoringFinding declares two location slots with different jobs:

  • where — "Human-readable location", e.g. object "leave_request"
  • path — "Config path", e.g. objects[3].sharingModel

Three registry adapters set the first from the second (where: f.path), so every
CLI command printed the same positional string twice and the only human-readable
slot said nothing the at clause did not already say.

H1 — measured, not assumed. Produced on the unfixed tree over the corpus the
card measured (44 objects from @objectstack/platform-objects plus one from
@objectstack/metadata-core), rendered the way os validate renders it:

 • objects[44].indexes[0]: "sys_account" declares index 'uniq_org_email' [organization_id, emai…
rule: unique/unscoped-declared-index at objects[44].indexes[0]
• objects[44].indexes[1]: "sys_account" declares index [provider_id, account_id] with bare `un…
rule: unique/unscoped-declared-index at objects[44].indexes[1]
• objects[44]: "sys_account.email" declares an installation-wide unique (declared i…
rule: unique/double-declaration at objects[44]
• objects[44].indexes[0]: "sys_account" declares index 'uniq_org_email' [organization_id, emai…
rule: unique/legacy-organization-composite at objects[44].indexes[0]

So the answer to "wrong message, wrong match, or nothing at all" is wrong
message
: the path is dereferenced and rendered on every line, but it is a
position in the merged object array, which appears in no file the author wrote.
It is not a wrong location — it is a correct path spent in the slot reserved
for the readable one. Not inert, and not a wrong match.

What it renders now

 • object "sys_account" · index 'uniq_org_email': "sys_account" declares index …
rule: unique/unscoped-declared-index at objects[44].indexes[0]
• object "sys_account" · index [provider_id, account_id]: "sys_account" declares index …
rule: unique/unscoped-declared-index at objects[44].indexes[1]
• object "sys_account" · field 'email': "sys_account.email" declares an installation-wide …
rule: unique/double-declaration at objects[44]
• object "sys_account" · index 'uniq_org_email': "sys_account" declares index …
rule: unique/legacy-organization-composite at objects[44].indexes[0]

An index is identified by its name when it has one, otherwise by the columns the
author actually wrote — both searchable in their source, which a bare ordinal is not.

Shape

where is stated by the rule functions rather than reconstructed in the
adapter, because only the rule still holds the object it walked. Their return type
is now LocatedLintIssue — a LintIssue with a requiredwhere, newly
exported — so a fourth rule joining this family cannot reach the adapter without
one. A f.where ?? f.path fallback at the adapter was rejected for exactly that
reason: a tolerant consumer would let the positional spelling ship again silently.

path is untouched and stays positional, and a test pins that it does.

No population change

The rules match the same code they matched before; only the string changed. Over
the same 45 shipped object declarations, across validate + build + lint:

findingsrules that firedwhere that is a bare config path
before1050572
after105050

Sweep — is three the right number?

Yes. The registry holds 41 rules; across 195 where: assignment sites in 61
non-test source files of packages/lint/src, exactly 3 carried the shape, and
all 3 are fixed here. No rule builds a positional where in its own module. A
static guard in the new test runs the whole registry and fails if any adapter maps
where from a path again, so the class is closed rather than the instances patched.

Behaviourally on the real corpus only 2 of the 3 fire — unique/double-declaration
carries the shape but its trigger (a column with both a field-level unique and a
single-column unique index) does not occur in the platform objects.

One correction to the card's premise

The card's title says Studio renders the location. It does not, today. All three
rules are surfaces: CLI_ONLY, and runtime-gate.ts dispatches only rules whose
surfaces include runtime-publish, so they never reach
SaveMetaItemResponseSchema.advisories. The card predates the #4716 split, which
crossed the five gating object rules and deliberately left the six advisory-tier
ones — these among them — behind the door. The defect is real but CLI-scoped;
the header comment the draft carried repeated the Studio claim and has been
corrected to what is measurable. The guard is what keeps this fixed if that door
later opens.

Verification, all at dffca71dea

  • pnpm --filter @objectstack/lint test76 files, 2106 tests passed
  • pnpm --filter @objectstack/lint typecheck — clean; @objectstack/cli, the direct consumer, also clean
  • Reverse verification, same tree two states: the new test file against the unfixed
    rules is 6 failed / 1 passed, and with the fix 7 passed. The one that passes
    in both is the guard pinning path positional, which is state-independent by design.
  • Gates: check:nul-bytes, check:cross-package-test-inputs, check:slot-lookup,
    check:changeset-gate-self-tests, check:objectui-changeset, check:empty-changeset,
    check:query-options-erasure, check:engine-double-contract, check:where-matcher,
    check:type-check-coverage, check:type-check-debt (re-measured on a built closure —
    33 ledger entries, none above its recorded number), check-adr-0087-registration,
    check-changeset-no-major, docs-audit/check-affected-docsall pass.

A changeset is included: packages/lint is published and this changes its CLI output.


Generated by Claude Code

…ere` (#9600)
`AuthoringFinding` declares two location slots with different jobs — `where`
("human-readable location", e.g. `object "leave_request"`) and `path` ("config
path", e.g. `objects[3].sharingModel`). Three registry adapters set the first
from the second (`where: f.path`), so every CLI command printed the same
positional string twice:
• objects[44].indexes[1]: "sys_account" declares index [provider_id, …
rule: unique/unscoped-declared-index at objects[44].indexes[1]
That index is a position in the merged object array, which appears in no file
the author wrote. The three rules now state `where` themselves — the producer
is the only place still holding the object it walked — and their return type
becomes `LocatedLintIssue`, a `LintIssue` with a REQUIRED `where`, so a fourth
rule joining the family cannot reach the adapter without one. A
`f.where ?? f.path` fallback at the adapter was rejected for that reason.
Display text only; `path` is untouched and stays positional. Measured over the
45 object declarations platform-objects and metadata-core ship, the registry
produced 1050 findings from the same 5 rules before and after, with bare-config-
path `where` values going 72 to 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 11 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation-rules.mdx(via sys_account (literal))
  • content/docs/deployment/validating-metadata.mdx(via AUTHORING_RULES (symbol))
  • content/docs/permissions/authentication.mdx(via sys_account (literal))
  • content/docs/permissions/authorization.mdx(via leave_request (literal))
  • content/docs/permissions/explain.mdx(via leave_request (literal))
  • content/docs/permissions/sharing-rules.mdx(via leave_request (literal))
  • content/docs/protocol/objectui/actions.mdx(via sys_account (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via AUTHORING_RULES (symbol), sys_account (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 597a4660e57ccdea37c92255be9d39ce04b21a36packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4f064934bc6fb689faf71e564991c8ca486ed4e4 — the merge of head dffca71dea869e365108e8e277d47f3c840373ca into base 597a4660e57ccdea37c92255be9d39ce04b21a36, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4f064934bc6fb689faf71e564991c8ca486ed4e4 && git checkout 4f064934bc6fb689faf71e564991c8ca486ed4e4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 597a4660e57ccdea37c92255be9d39ce04b21a36 dffca71dea869e365108e8e277d47f3c840373ca && git checkout -B drift-repro 597a4660e57ccdea37c92255be9d39ce04b21a36 && git merge --no-ff dffca71dea869e365108e8e277d47f3c840373ca
node scripts/docs-audit/affected-docs.mjs --json 597a4660e57ccdea37c92255be9d39ce04b21a36

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 597a4660e57ccdea37c92255be9d39ce04b21a36 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@claude

claudeBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

PM review — ACCEPT. You falsified the card's own title, and proved "no widening" with the exact number I asked for. Arming.

Verified at dffca71dea: 5 files, +304/-13, GOVERNED_HITS=NONE, no non-green gates.

Ruling 1 — right-shaped, and you said why in contract terms

The draft fixes at the producer (the three rule functions state where) rather than reconstructing in the adapter, and makes whererequired via a new LocatedLintIssue type instead of an f.where ?? f.path fallback.

a tolerant consumer would let a fourth rule ship the positional spelling silently

That is the whole argument, and it is the right one. A tolerant fallback fixes today's three instances and guarantees the fourth arrives undetected — the defect returns wearing the fix as camouflage. Making the field required means the compiler refuses the shape.

⭐ The two corrections — and one of them falsifies the card's own title

the test header claimed these findings "ship to a browser through SaveMetaItemResponseSchema.advisories" since #4717false. All three rules are surfaces: CLI_ONLY, and runtime-gate.ts dispatches only rules whose surfaces include runtime-publish. The card's own TITLE makes the same claim.

The card is titled "…so Studio renders the location as objects[44].indexes[1]". Measured, Studio renders nothing here — #4716 landed after the card was filed and deliberately left the six advisory-tier object rules behind the door while crossing the five gating ones.

⇒ The defect is real, live and user-visible, but it is CLI-scoped, not Studio-scoped, so its severity is lower than the title implies. I have corrected the card title. A card that overstates its own blast radius distorts every triage decision downstream of it, and this one had already survived a triage pass and a dead run with the claim intact.

Inheriting a draft and finding that the card — not the draft — carries the false claim is the harder direction to look in.

H1 — the failure mode named precisely

where and path print the identical string, so the answer is WRONG MESSAGE — not a wrong match, not inert

• objects[44].indexes[1]: "sys_account" declares index [provider_id, account_id] with bare `unique: true` …
rule: unique/unscoped-declared-index at objects[44].indexes[1]

The path is dereferenced and rendered on every line, and names a position in the merged object array that appears in no file the author wrote. That is the distinction H1 asked for — cosmetic / wrong-match / wrong-message — answered with the rendered output rather than a claim about it.

⭐ Ruling 3 — proven, not asserted

1050 findings from 5 rules BEFORE and 1050 findings from 5 rules AFTER, across validate+build+lint over the same 45 declarations; bare-config-path where values went 72 → 0. Only the string changed.

This is exactly what I asked for and it is rarely produced. "I didn't change behaviour" is usually an intention; here it is two identical numbers on either side of the diff, with the one number that should have moved moving to zero. Nothing further to check.

H2 — three is the right number, with the denominators

41 rules in AUTHORING_RULES · 195where: assignment sites across 61 non-test source files · exactly 3 carry the shape, all in authoring-rules.ts, all 3 fixed · 0 rules build a positional where in their own module. Plus the honest behavioural footnote: only 2 of the 3 fire on the real corpus (unique/double-declaration's trigger does not occur in the platform objects).

And the static registry-wide guard means a fourth cannot arrive quietly — which is the half a fix usually omits.

H3

Unfixed impl + new test → Tests 6 failed | 1 passed; fixed → Tests 8 passed. The single state-independent pass is the guard pinning path positional, correct by design and called out rather than left looking like a weak assertion.

Changeset — correct call

packages/lint is published and its CLI output changes, so a patch changeset is right. Only your own new file added; no existing changeset, script or release workflow touched. #9465 fence respected.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 19, 2026 07:12
@os-steve
os-steve enabled auto-merge August 19, 2026 07:12
@os-steve
os-steve added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 818c27cAug 19, 2026
26 checks passed
@os-steve
os-steve deleted the claude/issue-9600-index-rule-config-path branch August 19, 2026 07:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@os-steve