Skip to content

fix(lint): security-role-word visits objects[].fieldGroups[] — the heading above the fields it already judged - #18850

Merged
os-bill merged 3 commits into
mainfrom
claude/issue-18306-fieldgroups-role-word
Sep 18, 2026
Merged

os-bill merged 3 commits into
mainfrom
claude/issue-18306-fieldgroups-role-word

Conversation

@os-bill

@os-bill os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18306

validateSecurityRoleWord — the ADR-0090 D3 vocabulary freeze — visited seven declaration surfaces and not the field-group heading that renders directly above the fields it was already judging. objects[].fieldGroups[] is now visited, both halves of it.

Clause-②: no (narrowing) — the rule refuses more than it did. No key is added to any published payload and no public surface grows, so lanes/spec.md's widening test is not met; narrowing is still a semantic-surface change, which is why it is declared rather than shipped silently.

The card did not pre-judge the answer, so this is the reading that decided it

The card offered two acceptable endings: add the visit, or determine that field groups are presentation-only and record that reasoning in the docblock. The evidence went one way.

"Presentation-only" cannot be the discriminator, because the rule already refuses the word in labels that carry no permission semantics at all. Measured on this tree, before any change: a stack whose field is labelled Role Of Record produces error security-role-word @ objects[0].fields.duty.label. object.label and action.label are refused on the same footing. If presentation were the test, three of the seven surfaces would not be scanned.

What the ban actually says. ADR-0090 D3: "'role' is a reserved-forbidden word in identifiers, UI copy, and documentation, enforced by lint", and the ADR's own rule table reads "The word role in identifiers/labels → error". A field group declares both halves: key is an identifier (Field.group assigns membership by it, and a layout section's group inherits the whole group by it, ADR-0085 §5), and label is the section header an admin reads. It is inside the ban by the ban's own terms.

What excludes pages, views and components is a different fact, and it survives untouched: role there is the HTML/ARIA attribute — a machine word with a fixed foreign meaning, not a word the author picked. No such collision exists on a group header.

So the gap was the #7220 shape, one grain finer than the one this function's own split exists to avoid. On a single record page a field labelled Role Of Record was refused while the group heading directly above it, Account & Role, walked through: the author renames the field and the heading keeps the word.

key is visited beside label for the same reason the other six surfaces visit name beside label. Refusing fields: { role_data } while admitting fieldGroups: [{ key: 'role_data' }] is that same shape again. This is inside the surface the card named (objects[].fieldGroups[]), not a widening to a new one.

Not widened, deliberately: listViews, recordTypes and the other label-bearing surfaces. They are unmeasured here, not judged; each needs the reading this one got before it is in or out. The docblock says so, so the next reader need not re-derive it.

LIT control — silent before, reported after

Both runs are the same harness over the same fixtures, on this branch, either side of the rule edit. The findings are the whole output, not a summary.

fixture before after
fieldGroups: [{ key: 'assignment', label: 'Account & Role' }] 0 findings 1 — error security-role-word @ objects[0].fieldGroups[0].label
fieldGroups: [{ key: 'role_info', label: 'Assignment' }] 0 findings 1 — error security-role-word @ objects[0].fieldGroups[0].key

After, verbatim:

error security-role-word @ objects[0].fieldGroups[0].label  (field group "showcase_contact.assignment")
    field group label "Account & Role" uses the reserved word "role" (ADR-0090 D3).
error security-role-word @ objects[0].fieldGroups[0].key  (field group "showcase_contact.role_info")
    field group key "role_info" uses the reserved word "role" — the platform vocabulary is
    permission_set (capability), position (distribution), business_unit (hierarchy) (ADR-0090 D3).

The fix-it says key, not name, because ObjectFieldGroupSchema spells the identifier key and declares name as a rejected alias — a message naming name would point the author at a key the schema refuses.

DARK control — everything else reads 0 change

Diffing the two full harness runs, the four lines quoted above are the only lines that differ. Specifically:

dark fixture before after
the seven surfaces visited today (object name, field name, field label, action name, action label, permission set, position label, app name, book label) 9 findings 9 findings, byte-identical — diff over that block reports 0 lines
a field group with no reserved word (contact / work / status / notes / Payroll — Controlled Rollout) 0 0
the sys_member.role system-object exemption 0 0
a field group carrying the word on a sys_ object 0 0

The last row is the one the placement had to earn: the visit sits inside the isSystemObject guard, so a platform object whose fields are exempt cannot have a gated heading above them.

Corpus — how many existing declarations redden in this repository

Zero. Measured on objectstack-ai/objectstack at d510921fc8, with the same harness on either side of the edit:

corpus field-group entries findings before findings after
examples/app-showcase (22 objects) 6 0 0
examples/app-crm (6 objects) 0 0 0
examples/app-todo (1 object) 0 0 0
examples/app-multi-package (2 objects) 0 0 0

Cross-checked two further ways: every file in the tree that declares a fieldGroups: array (20 of them, fixtures included) was scanned for a reserved word in a key or label — no hits; and check:doc-security-posture is green over 27 ObjectSchema.create examples in 227 marked blocks across 239 prose files. So no declaration data is touched by this PR, and none needs to be.

Changeset level, and why

minor, on @objectstack/lint, carrying a BREAKING banner.

It is breaking in the accept-set sense — a declaration that passes today can fail tomorrow — and major is refused outright by check-changeset-no-major during the launch window, where breaking-ness is carried by the banner plus the ADR-0087 disposition rather than by the bump. patch is wrong for the same reason it would be wrong for any accept-set narrowing: the level would say a consumer can upgrade without reading anything. skip-changeset is wrong because @objectstack/lint is published (17.4.0, files: ["dist", …]) and its shipped behaviour moves.

ADR-0087 disposition: not-required (no-migration-prescription). No key, symbol, enum member or stored value moves — a stored metadata row is structurally identical before and after — so objectstack migrate meta has nothing to rewrite, and there is no FROM-TO mapping to state because there is no single replacement: the author picks a domain word, and the refusal names the platform vocabulary at the exact path. pnpm check:adr-0087-registration reads the disposition and the clause-② arm and passes.

Verification

Run at d510921fc8 (after the last commit, which is a clean merge of origin/main).

  • pnpm --filter @objectstack/lint test — 104 files, 3886 passed, 5 skipped.
  • pnpm --filter @objectstack/lint typecheck — green; check:test-typecheck holds its existing ledger (2 files / 6 errors / 2 pinned signatures), unchanged.
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0 over the whole repository, no narrowing claimed.
  • Derived gate families (scripts/pm/dispatch-gates.mjs --commands, 58 commands, re-derived after the merge): 55 green, including check:adr-0087-registration, check:changeset-no-major, check:empty-changeset, check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:docs-transcript-drift, check:doc-security-posture, check:doc-authoring, check:published-files.
  • NOT MEASURED, declared to CI — three families exit 3 (PREREQUISITE NOT MET, which is neither a pass nor a finding) because they read a whole-repo build: check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt. Build Core and TypeScript Type Check own those runs.
  • NOT MEASURED — packages/cli's spawn-based tier: the CLI cannot load its own command table without a repo build (probed directly: exit 2, MODULE_NOT_FOUND on @objectstack/types/dist/index.mjs, on a config carrying neither a flow nor a field group). validate-build-gate-parity.test.ts did run in-process: 21 passed. This tier is CI's; the diff touches no integration-tier file and no spawn entry point.

New pins

The rule's own test surface grew rather than the behaviour being left to prose:

  • the heading and the field it heads are asserted in one stack, in path order — apart, they are two passing tests that say nothing about what was wrong;
  • the key spelling in the fix-it, asserted positively and negatively;
  • the silent shape (the showcase's four real groups plus a Payroll near-miss);
  • the system-object exemption on a field group;
  • the #5017 meta-guard gains a group receiver row bound to ObjectSchema.fieldGroups[], so reading group.name — the alias the schema rejects — fails before review rather than after; and fieldGroups joins obj's declared-key list.

Acceptance notes

Noted, not filed:


Generated by Claude Code

The ADR-0090 D3 vocabulary freeze visited seven declaration surfaces and
not the field-group header that sits directly above the fields it already
polices, so on one record page a field labelled "Role Of Record" was
refused while the group header "Account & Role" walked through.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
Behavioural pins for the new surface (heading beside the field it heads,
the `key` spelling in the fix-it, the silent shape, the system-object
exemption), the meta-guard rows that make an undeclared read on it fail
before review, and the breaking changeset with its ADR-0087 disposition.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

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 631dcbd4b93c3f86d02080bc129a611f0619ed23 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fbc20eb416ff90715f981a3ab7a86f7b0b8b21b8 — the merge of head d510921fc820431cc97029efc20fc331c02eebec into base 631dcbd4b93c3f86d02080bc129a611f0619ed23, 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 fbc20eb416ff90715f981a3ab7a86f7b0b8b21b8 && git checkout fbc20eb416ff90715f981a3ab7a86f7b0b8b21b8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 631dcbd4b93c3f86d02080bc129a611f0619ed23 d510921fc820431cc97029efc20fc331c02eebec && git checkout -B drift-repro 631dcbd4b93c3f86d02080bc129a611f0619ed23 && git merge --no-ff d510921fc820431cc97029efc20fc331c02eebec

node scripts/docs-audit/affected-docs.mjs --json 631dcbd4b93c3f86d02080bc129a611f0619ed23

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 18, 2026
@os-bill
os-bill marked this pull request as ready for review September 18, 2026 00:52
@os-bill
os-bill added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit f2044ef Sep 18, 2026
36 checks passed
@os-bill
os-bill deleted the claude/issue-18306-fieldgroups-role-word branch September 18, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] validateSecurityRoleWord skips fieldGroups[].label — the same role word is refused on a field and admitted on the group heading above it

2 participants