Skip to content

Commit f2044ef

Browse files
os-billclaude
andauthored
fix(lint): security-role-word visits objects[].fieldGroups[] — the heading above the fields it already judged (#18850)
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: - `packages/lint/src/authoring-rules.ts` enumerates this rule's collections twice, in a comment and in `surfaceReason`, as *"six collections (objects, fields, actions, permission sets, positions, apps — plus books)"*. Field groups are a sub-surface of `objects`, which is already carried and mapped, so the #7220 wall argument those two passages make is unchanged and still correct. The enumeration is now incomplete, though. It is outside this PR's declared file surface and is a prose accuracy matter, not a defect: no gate reads it and no verdict depends on it. Whoever next moves that registry entry — the `positions`/`apps` crossing it describes — is the natural carrier. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2cf0f1 commit f2044ef

3 files changed

Lines changed: 218 additions & 5 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: the ADR-0090 D3 vocabulary freeze visits `objects[].fieldGroups[]` (#18306)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) an authoring-time lint rule reports a word on one more declaration surface; no key, symbol, enum member or stored value moves, so a stored metadata row is structurally identical before and after and `objectstack migrate meta` has nothing to rewrite -->
8+
9+
**BREAKING** in the accept-set sense — a declaration that passes today can fail tomorrow.
10+
Landing in the launch window as `minor` (the lockstep convention: `major` is refused by
11+
`check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087
12+
disposition above).
13+
14+
**Clause-②: no (narrowing)** — the rule refuses more than it did; no key is added to any
15+
published payload and no public surface grows, so `lanes/spec.md`'s widening test is not
16+
met. Narrowing is still a semantic-surface change, which is why it is declared here rather
17+
than shipped silently.
18+
19+
`security-role-word` (ADR-0090 D3) judged an object's name, field names and labels, action
20+
names and labels, permission sets, positions, apps and books — and not the field-group
21+
heading that renders directly above the fields it was already judging. So on one record page
22+
a field labelled `Role Of Record` was refused while the group header above it,
23+
`Account & Role`, was admitted: the author renames the field and the heading keeps the word.
24+
That is the exact "refused on one surface, admitted on another" shape (#7220) that this
25+
rule's own split was made to avoid, one grain finer.
26+
27+
Both halves of the group declaration are judged, as on every other surface: `key` is an
28+
identifier (`Field.group` assigns membership by it, and a layout section's `group` inherits
29+
the group by it, ADR-0085 §5), `label` is the header an admin reads. ADR-0090 D3 bans the
30+
word in "identifiers, UI copy, and documentation", and a field group declares both.
31+
32+
Pages, views and components stay out, unchanged: `role` there is the HTML/ARIA attribute — a
33+
machine word with a fixed foreign meaning, not a word the author picked. `listViews`,
34+
`recordTypes` and the other label-bearing surfaces are deliberately not swept in with this;
35+
each needs its own reading first.
36+
37+
**What an author does.** Nothing is renamed for you and nothing is auto-rewritten: the
38+
platform vocabulary is `permission_set` (capability), `position` (distribution),
39+
`business_unit` (hierarchy), and the refusal itself names it at the exact path
40+
(`objects[i].fieldGroups[j].key` / `.label`). A group heading reading `Account & Role`
41+
becomes `Account & Assignment`; a group keyed `role_info` becomes `assignment`, and the
42+
member fields' `group` pointers move with it.
43+
44+
Unaffected: a system object (`sys_*` / `isSystem: true`) keeps the better-auth exemption on
45+
its field groups exactly as it keeps it on its fields, and a group carrying no reserved word
46+
is silent.

‎packages/lint/src/validate-security-posture.test.ts‎

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,89 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => {
546546
).toEqual([]);
547547
});
548548

549+
// ── The field-group surface (#18306) ────────────────────────────────
550+
// The gap this closed was the #7220 shape one grain finer than the one
551+
// `validateSecurityRoleWord`'s own split exists to avoid: the field label
552+
// below is refused, so the group HEADING above it has to be refused too, or
553+
// the author renames the field and the heading keeps the word. The pair is
554+
// asserted together, in one stack, because apart they are two passing tests
555+
// that say nothing about the thing that was wrong.
556+
it('refuses the reserved word on a field-group heading, beside the field it heads', () => {
557+
const findings = validateSecurityRoleWord({
558+
objects: [
559+
{
560+
name: 'showcase_contact',
561+
label: 'Contact',
562+
sharingModel: 'private',
563+
fields: { duty: { name: 'duty', label: 'Role Of Record', group: 'assignment' } },
564+
fieldGroups: [{ key: 'assignment', label: 'Account & Role' }],
565+
},
566+
],
567+
});
568+
expect(findings.map((f) => f.path)).toEqual([
569+
'objects[0].fields.duty.label',
570+
'objects[0].fieldGroups[0].label',
571+
]);
572+
expect(findings.every((f) => f.severity === 'error' && f.rule === SECURITY_ROLE_WORD)).toBe(true);
573+
});
574+
575+
it('refuses the reserved token in a field-group KEY, and names `key` (not `name`) in the fix-it', () => {
576+
// `ObjectFieldGroupSchema` spells the identifier `key` and REJECTS `name`
577+
// as an alias, so a message naming `name` would point the author at a key
578+
// the schema refuses.
579+
const findings = validateSecurityRoleWord({
580+
objects: [
581+
{
582+
name: 'showcase_contact',
583+
sharingModel: 'private',
584+
fieldGroups: [{ key: 'role_info', label: 'Assignment' }],
585+
},
586+
],
587+
});
588+
expect(findings).toHaveLength(1);
589+
expect(findings[0]).toMatchObject({
590+
severity: 'error',
591+
rule: SECURITY_ROLE_WORD,
592+
path: 'objects[0].fieldGroups[0].key',
593+
where: 'field group "showcase_contact.role_info"',
594+
});
595+
expect(findings[0]!.message).toContain('field group key "role_info"');
596+
expect(findings[0]!.message).not.toContain('field group name');
597+
});
598+
599+
it('stays silent on a field group with no reserved word — the showcase shape', () => {
600+
// The four groups `examples/app-showcase` authors on `showcase_contact`,
601+
// plus the near-miss the sibling test pins one grain up (`payroll`).
602+
expect(
603+
validateSecurityRoleWord({
604+
objects: [
605+
{
606+
name: 'showcase_contact',
607+
label: 'Contact',
608+
sharingModel: 'private',
609+
fieldGroups: [
610+
{ key: 'contact', label: 'Contact' },
611+
{ key: 'work', label: 'Work' },
612+
{ key: 'status', label: 'Status' },
613+
{ key: 'notes', label: 'Notes' },
614+
{ key: 'payroll', label: 'Payroll — Controlled Rollout' },
615+
],
616+
},
617+
],
618+
}),
619+
).toEqual([]);
620+
});
621+
622+
it('exempts a field group on a system object, exactly as it exempts the fields under it', () => {
623+
// The visit lives INSIDE the `isSystemObject` guard on purpose: a platform
624+
// object whose fields are exempt cannot have a gated heading above them.
625+
expect(
626+
validateSecurityRoleWord({
627+
objects: [{ name: 'sys_member', fieldGroups: [{ key: 'role_info', label: 'Organization Role' }] }],
628+
}),
629+
).toEqual([]);
630+
});
631+
549632
// ── Rule: security-private-no-readscope (info) ──────────────────────
550633
it('emits info when a set grants plain read on a private object without depth', () => {
551634
const findings = validateSecurityPosture({
@@ -1262,7 +1345,10 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
12621345
receiver: 'obj',
12631346
// `security` is absent, and that is the #5017 fix: `ObjectSchema` declares
12641347
// the OWD dials FLAT and has no `security` envelope to nest them under.
1265-
expected: ['actions', 'externalSharingModel', 'fields', 'isSystem', 'label', 'name', 'sharingModel'],
1348+
// [#18306] `fieldGroups` joined when the ADR-0090 D3 vocabulary freeze
1349+
// started visiting the group headings that sit above the fields it already
1350+
// judged.
1351+
expected: ['actions', 'externalSharingModel', 'fieldGroups', 'fields', 'isSystem', 'label', 'name', 'sharingModel'],
12661352
declaredBy: 'ObjectSchema',
12671353
keys: () => Object.keys(ObjectSchema.shape),
12681354
},
@@ -1296,6 +1382,17 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
12961382
declaredBy: 'ObjectSchema.actions[]',
12971383
keys: () => shapeKeysOf(ObjectSchema.shape.actions),
12981384
},
1385+
// [#18306] The field-group heading surface. `key`, not `name`: the schema
1386+
// spells the identifier `key` and declares `name` as a REJECTED alias, so
1387+
// reading `group.name` here would be the #5017 shape — a consumer tolerating
1388+
// a spelling its own schema refuses by name. This entry is what makes that
1389+
// regression fail before review rather than after.
1390+
{
1391+
receiver: 'group',
1392+
expected: ['key', 'label'],
1393+
declaredBy: 'ObjectSchema.fieldGroups[]',
1394+
keys: () => shapeKeysOf(ObjectSchema.shape.fieldGroups),
1395+
},
12991396
{
13001397
receiver: 'app',
13011398
expected: ['label', 'name'],
@@ -1528,6 +1625,19 @@ const REACHABILITY_CORPUS: Array<{ label: string; stack: Record<string, unknown>
15281625
},
15291626
{ label: 'role-word (identifier)', stack: { objects: [objectFixture({ name: 'user_role', sharingModel: 'private' })] } },
15301627
{ label: 'role-word (label)', stack: { objects: [objectFixture({ name: 'user_duty', label: 'User Role', sharingModel: 'private' })] } },
1628+
// [#18306] Not a new push site — the same two branches, reached through the
1629+
// field-group surface. It earns its place in THIS corpus for the other
1630+
// guarantee the corpus makes: that the keys the rule reads are keys an
1631+
// author can legally write, which is the claim `group.key` / `group.label`
1632+
// rests on.
1633+
{
1634+
label: 'role-word (field-group key)',
1635+
stack: { objects: [objectFixture({ name: 'user_duty', sharingModel: 'private', fieldGroups: [{ key: 'role_info', label: 'Assignment' }] })] },
1636+
},
1637+
{
1638+
label: 'role-word (field-group label)',
1639+
stack: { objects: [objectFixture({ name: 'user_duty', sharingModel: 'private', fieldGroups: [{ key: 'assignment', label: 'Account & Role' }] })] },
1640+
},
15311641
{
15321642
label: 'book-audience-unknown-set',
15331643
stack: { books: [{ name: 'guide', label: 'Guide', slug: 'guide', groups: [], audience: { permissionSet: 'nobody_declares_this' } }] },

‎packages/lint/src/validate-security-posture.ts‎

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,13 +1045,42 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number }
10451045
* [ADR-0090 D3] The `security-role-word` vocabulary freeze, as its own rule.
10461046
*
10471047
* Scope: security-relevant identifiers/labels across SIX collections — objects
1048-
* (names, field names, action names), permission sets, positions, apps, books.
1048+
* (names, field names, action names, field-group keys and labels), permission
1049+
* sets, positions, apps, books.
10491050
* Pages/views/components are NOT scanned — `role` there is HTML/ARIA
10501051
* semantics, not permission vocabulary. The sole platform exception
10511052
* (better-auth `sys_member.role`) is a system object, which app stacks never
10521053
* author. Books entered the security-relevant set when `book.audience` became
10531054
* a permission-model reference (ADR-0046 §6.7 / ADR-0090).
10541055
*
1056+
* ## Why `objects[].fieldGroups[]` IS scanned, and a page section is not
1057+
*
1058+
* [#18306] Measured, not assumed — the question is which fact separates the
1059+
* scanned surfaces from the excluded ones, and it is NOT "does this key carry
1060+
* permission semantics". `object.label`, `field.label` and `action.label`
1061+
* carry none either, and all three are scanned: the ban ADR-0090 D3 writes is
1062+
* on the WORD, in "identifiers, UI copy, and documentation". A field group
1063+
* declares both halves — `key` is an identifier (`Field.group` assigns
1064+
* membership by it, and a layout section's `group` inherits the whole group by
1065+
* it, ADR-0085 §5), and `label` is the section header an admin reads on the
1066+
* record page. So it is inside the ban by the ban's own terms.
1067+
*
1068+
* What excludes pages/views/components is a different fact: `role` there is
1069+
* the HTML/ARIA attribute — a machine word with a fixed foreign meaning, not a
1070+
* word the author picked. No such collision exists on a group header.
1071+
*
1072+
* Leaving the surface out built the #7220 shape one grain FINER than the one
1073+
* this function's own split exists to avoid: on a single record page a field
1074+
* labelled `Role Of Record` was refused while the group header directly above
1075+
* it, `Account & Role`, walked through — the author renames the field and the
1076+
* heading keeps the word. `key` is visited beside `label` for the same reason
1077+
* the other six surfaces visit name beside label: refusing `fields: { role }`
1078+
* while admitting `fieldGroups: [{ key: 'role' }]` is that shape again.
1079+
*
1080+
* Deliberately NOT widened with it: `listViews`, `recordTypes` and the other
1081+
* label-bearing surfaces. They are unmeasured here, not judged — each needs
1082+
* the same reading this one got before it is in or out.
1083+
*
10551084
* ## Why this is a separate function from {@link validateSecurityPosture}
10561085
*
10571086
* [#8310] Not taste — a surface boundary. When the rest of the D7 block
@@ -1076,15 +1105,30 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] {
10761105
const objects = recordsOf(stack.objects);
10771106
const permissionSets = recordsOf(stack.permissions);
10781107

1079-
const flagRole = (kind: string, name: unknown, label: unknown, where: string, path: string) => {
1108+
/**
1109+
* `idKey` is the spelling of the IDENTIFIER slot on the surface being
1110+
* flagged. Six of the seven spell it `name`; `ObjectFieldGroupSchema` spells
1111+
* it `key` and REJECTS `name` as an alias, so a fix-it naming `name` there
1112+
* would point the author at a key the schema refuses — and the derived
1113+
* sibling-label path would address a child of the identifier rather than the
1114+
* identifier's neighbour.
1115+
*/
1116+
const flagRole = (
1117+
kind: string,
1118+
name: unknown,
1119+
label: unknown,
1120+
where: string,
1121+
path: string,
1122+
idKey: 'name' | 'key' = 'name',
1123+
) => {
10801124
if (identifierHasRoleToken(name)) {
10811125
findings.push({
10821126
severity: 'error',
10831127
rule: SECURITY_ROLE_WORD,
10841128
where,
10851129
path,
10861130
message:
1087-
`${kind} name "${String(name)}" uses the reserved word "role" — the platform vocabulary ` +
1131+
`${kind} ${idKey} "${String(name)}" uses the reserved word "role" — the platform vocabulary ` +
10881132
`is permission_set (capability), position (distribution), business_unit (hierarchy) (ADR-0090 D3).`,
10891133
hint: `Rename using 'position' for distribution groups or a domain word (e.g. 'function', 'duty').`,
10901134
});
@@ -1093,7 +1137,7 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] {
10931137
severity: 'error',
10941138
rule: SECURITY_ROLE_WORD,
10951139
where,
1096-
path: `${path.replace(/\.name$/, '')}.label`,
1140+
path: `${path.replace(/\.(?:name|key)$/, '')}.label`,
10971141
message: `${kind} label "${String(label)}" uses the reserved word "role" (ADR-0090 D3).`,
10981142
hint: `Relabel with 'Position' (distribution) or a domain word — admins must meet ONE vocabulary.`,
10991143
});
@@ -1111,6 +1155,19 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] {
11111155
for (const [ai, action] of recordsOf(obj.actions).entries()) {
11121156
flagRole('action', action.name, action.label, `action "${objName}.${String(action.name ?? '?')}"`, `objects[${i}].actions[${ai}].name`);
11131157
}
1158+
// [#18306] Field groups sit INSIDE this loop deliberately: the system-object
1159+
// exemption above (better-auth `sys_member`) has to cover a group header on
1160+
// a platform object exactly as it covers the fields under it.
1161+
for (const [gi, group] of recordsOf(obj.fieldGroups).entries()) {
1162+
flagRole(
1163+
'field group',
1164+
group.key,
1165+
group.label,
1166+
`field group "${objName}.${String(group.key ?? '?')}"`,
1167+
`objects[${i}].fieldGroups[${gi}].key`,
1168+
'key',
1169+
);
1170+
}
11141171
}
11151172
for (let i = 0; i < permissionSets.length; i++) {
11161173
const ps = permissionSets[i];

0 commit comments

Comments
 (0)