From a11be6e5e61a001dc170b68e093d990b80257556 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 23:53:20 +0000 Subject: [PATCH 1/2] fix(lint): security-role-word visits objects[].fieldGroups[] 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 --- .../lint/src/validate-security-posture.ts | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 613f259d3f8..69eaf3fdf5d 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -1045,13 +1045,42 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } * [ADR-0090 D3] The `security-role-word` vocabulary freeze, as its own rule. * * Scope: security-relevant identifiers/labels across SIX collections — objects - * (names, field names, action names), permission sets, positions, apps, books. + * (names, field names, action names, field-group keys and labels), permission + * sets, positions, apps, books. * Pages/views/components are NOT scanned — `role` there is HTML/ARIA * semantics, not permission vocabulary. The sole platform exception * (better-auth `sys_member.role`) is a system object, which app stacks never * author. Books entered the security-relevant set when `book.audience` became * a permission-model reference (ADR-0046 §6.7 / ADR-0090). * + * ## Why `objects[].fieldGroups[]` IS scanned, and a page section is not + * + * [#18306] Measured, not assumed — the question is which fact separates the + * scanned surfaces from the excluded ones, and it is NOT "does this key carry + * permission semantics". `object.label`, `field.label` and `action.label` + * carry none either, and all three are scanned: the ban ADR-0090 D3 writes is + * on the WORD, in "identifiers, UI copy, and documentation". 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 on the + * record page. So it is inside the ban by the ban's own terms. + * + * What excludes pages/views/components is a different fact: `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. + * + * Leaving the surface out built 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 header 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 }` + * while admitting `fieldGroups: [{ key: 'role' }]` is that shape again. + * + * Deliberately NOT widened with it: `listViews`, `recordTypes` and the other + * label-bearing surfaces. They are unmeasured here, not judged — each needs + * the same reading this one got before it is in or out. + * * ## Why this is a separate function from {@link validateSecurityPosture} * * [#8310] Not taste — a surface boundary. When the rest of the D7 block @@ -1076,7 +1105,22 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] { const objects = recordsOf(stack.objects); const permissionSets = recordsOf(stack.permissions); - const flagRole = (kind: string, name: unknown, label: unknown, where: string, path: string) => { + /** + * `idKey` is the spelling of the IDENTIFIER slot on the surface being + * flagged. Six of the seven spell it `name`; `ObjectFieldGroupSchema` spells + * it `key` and REJECTS `name` as an alias, so a fix-it naming `name` there + * would point the author at a key the schema refuses — and the derived + * sibling-label path would address a child of the identifier rather than the + * identifier's neighbour. + */ + const flagRole = ( + kind: string, + name: unknown, + label: unknown, + where: string, + path: string, + idKey: 'name' | 'key' = 'name', + ) => { if (identifierHasRoleToken(name)) { findings.push({ severity: 'error', @@ -1084,7 +1128,7 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] { where, path, message: - `${kind} name "${String(name)}" uses the reserved word "role" — the platform vocabulary ` + + `${kind} ${idKey} "${String(name)}" uses the reserved word "role" — the platform vocabulary ` + `is permission_set (capability), position (distribution), business_unit (hierarchy) (ADR-0090 D3).`, hint: `Rename using 'position' for distribution groups or a domain word (e.g. 'function', 'duty').`, }); @@ -1093,7 +1137,7 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] { severity: 'error', rule: SECURITY_ROLE_WORD, where, - path: `${path.replace(/\.name$/, '')}.label`, + path: `${path.replace(/\.(?:name|key)$/, '')}.label`, message: `${kind} label "${String(label)}" uses the reserved word "role" (ADR-0090 D3).`, hint: `Relabel with 'Position' (distribution) or a domain word — admins must meet ONE vocabulary.`, }); @@ -1111,6 +1155,19 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] { for (const [ai, action] of recordsOf(obj.actions).entries()) { flagRole('action', action.name, action.label, `action "${objName}.${String(action.name ?? '?')}"`, `objects[${i}].actions[${ai}].name`); } + // [#18306] Field groups sit INSIDE this loop deliberately: the system-object + // exemption above (better-auth `sys_member`) has to cover a group header on + // a platform object exactly as it covers the fields under it. + for (const [gi, group] of recordsOf(obj.fieldGroups).entries()) { + flagRole( + 'field group', + group.key, + group.label, + `field group "${objName}.${String(group.key ?? '?')}"`, + `objects[${i}].fieldGroups[${gi}].key`, + 'key', + ); + } } for (let i = 0; i < permissionSets.length; i++) { const ps = permissionSets[i]; From 7c01f9b10bc6628e0305fd750a961802aa8d9e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 23:57:01 +0000 Subject: [PATCH 2/2] test(lint): pin the field-group surface, and add the changeset 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 --- .changeset/18306-role-word-field-groups.md | 46 +++++++ .../src/validate-security-posture.test.ts | 112 +++++++++++++++++- 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 .changeset/18306-role-word-field-groups.md diff --git a/.changeset/18306-role-word-field-groups.md b/.changeset/18306-role-word-field-groups.md new file mode 100644 index 00000000000..2a3352f8076 --- /dev/null +++ b/.changeset/18306-role-word-field-groups.md @@ -0,0 +1,46 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: the ADR-0090 D3 vocabulary freeze visits `objects[].fieldGroups[]` (#18306) + + + +**BREAKING** in the accept-set sense — a declaration that passes today can fail tomorrow. +Landing in the launch window as `minor` (the lockstep convention: `major` is refused by +`check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087 +disposition above). + +**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 here rather +than shipped silently. + +`security-role-word` (ADR-0090 D3) judged an object's name, field names and labels, action +names and labels, permission sets, positions, apps and books — and not the field-group +heading that renders directly above the fields it was already judging. So on one record page +a field labelled `Role Of Record` was refused while the group header above it, +`Account & Role`, was admitted: the author renames the field and the heading keeps the word. +That is the exact "refused on one surface, admitted on another" shape (#7220) that this +rule's own split was made to avoid, one grain finer. + +Both halves of the group declaration are judged, as on every other surface: `key` is an +identifier (`Field.group` assigns membership by it, and a layout section's `group` inherits +the group by it, ADR-0085 §5), `label` is the header an admin reads. ADR-0090 D3 bans the +word in "identifiers, UI copy, and documentation", and a field group declares both. + +Pages, views and components stay out, unchanged: `role` there is the HTML/ARIA attribute — a +machine word with a fixed foreign meaning, not a word the author picked. `listViews`, +`recordTypes` and the other label-bearing surfaces are deliberately not swept in with this; +each needs its own reading first. + +**What an author does.** Nothing is renamed for you and nothing is auto-rewritten: the +platform vocabulary is `permission_set` (capability), `position` (distribution), +`business_unit` (hierarchy), and the refusal itself names it at the exact path +(`objects[i].fieldGroups[j].key` / `.label`). A group heading reading `Account & Role` +becomes `Account & Assignment`; a group keyed `role_info` becomes `assignment`, and the +member fields' `group` pointers move with it. + +Unaffected: a system object (`sys_*` / `isSystem: true`) keeps the better-auth exemption on +its field groups exactly as it keeps it on its fields, and a group carrying no reserved word +is silent. diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index 5027dc279c4..9354401be8d 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -546,6 +546,89 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => { ).toEqual([]); }); + // ── The field-group surface (#18306) ──────────────────────────────── + // The gap this closed was the #7220 shape one grain finer than the one + // `validateSecurityRoleWord`'s own split exists to avoid: the field label + // below is refused, so the group HEADING above it has to be refused too, or + // the author renames the field and the heading keeps the word. The pair is + // asserted together, in one stack, because apart they are two passing tests + // that say nothing about the thing that was wrong. + it('refuses the reserved word on a field-group heading, beside the field it heads', () => { + const findings = validateSecurityRoleWord({ + objects: [ + { + name: 'showcase_contact', + label: 'Contact', + sharingModel: 'private', + fields: { duty: { name: 'duty', label: 'Role Of Record', group: 'assignment' } }, + fieldGroups: [{ key: 'assignment', label: 'Account & Role' }], + }, + ], + }); + expect(findings.map((f) => f.path)).toEqual([ + 'objects[0].fields.duty.label', + 'objects[0].fieldGroups[0].label', + ]); + expect(findings.every((f) => f.severity === 'error' && f.rule === SECURITY_ROLE_WORD)).toBe(true); + }); + + it('refuses the reserved token in a field-group KEY, and names `key` (not `name`) in the fix-it', () => { + // `ObjectFieldGroupSchema` spells the identifier `key` and REJECTS `name` + // as an alias, so a message naming `name` would point the author at a key + // the schema refuses. + const findings = validateSecurityRoleWord({ + objects: [ + { + name: 'showcase_contact', + sharingModel: 'private', + fieldGroups: [{ key: 'role_info', label: 'Assignment' }], + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: SECURITY_ROLE_WORD, + path: 'objects[0].fieldGroups[0].key', + where: 'field group "showcase_contact.role_info"', + }); + expect(findings[0]!.message).toContain('field group key "role_info"'); + expect(findings[0]!.message).not.toContain('field group name'); + }); + + it('stays silent on a field group with no reserved word — the showcase shape', () => { + // The four groups `examples/app-showcase` authors on `showcase_contact`, + // plus the near-miss the sibling test pins one grain up (`payroll`). + expect( + validateSecurityRoleWord({ + objects: [ + { + name: 'showcase_contact', + label: 'Contact', + sharingModel: 'private', + fieldGroups: [ + { key: 'contact', label: 'Contact' }, + { key: 'work', label: 'Work' }, + { key: 'status', label: 'Status' }, + { key: 'notes', label: 'Notes' }, + { key: 'payroll', label: 'Payroll — Controlled Rollout' }, + ], + }, + ], + }), + ).toEqual([]); + }); + + it('exempts a field group on a system object, exactly as it exempts the fields under it', () => { + // The visit lives INSIDE the `isSystemObject` guard on purpose: a platform + // object whose fields are exempt cannot have a gated heading above them. + expect( + validateSecurityRoleWord({ + objects: [{ name: 'sys_member', fieldGroups: [{ key: 'role_info', label: 'Organization Role' }] }], + }), + ).toEqual([]); + }); + // ── Rule: security-private-no-readscope (info) ────────────────────── it('emits info when a set grants plain read on a private object without depth', () => { const findings = validateSecurityPosture({ @@ -1262,7 +1345,10 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s receiver: 'obj', // `security` is absent, and that is the #5017 fix: `ObjectSchema` declares // the OWD dials FLAT and has no `security` envelope to nest them under. - expected: ['actions', 'externalSharingModel', 'fields', 'isSystem', 'label', 'name', 'sharingModel'], + // [#18306] `fieldGroups` joined when the ADR-0090 D3 vocabulary freeze + // started visiting the group headings that sit above the fields it already + // judged. + expected: ['actions', 'externalSharingModel', 'fieldGroups', 'fields', 'isSystem', 'label', 'name', 'sharingModel'], declaredBy: 'ObjectSchema', keys: () => Object.keys(ObjectSchema.shape), }, @@ -1296,6 +1382,17 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s declaredBy: 'ObjectSchema.actions[]', keys: () => shapeKeysOf(ObjectSchema.shape.actions), }, + // [#18306] The field-group heading surface. `key`, not `name`: the schema + // spells the identifier `key` and declares `name` as a REJECTED alias, so + // reading `group.name` here would be the #5017 shape — a consumer tolerating + // a spelling its own schema refuses by name. This entry is what makes that + // regression fail before review rather than after. + { + receiver: 'group', + expected: ['key', 'label'], + declaredBy: 'ObjectSchema.fieldGroups[]', + keys: () => shapeKeysOf(ObjectSchema.shape.fieldGroups), + }, { receiver: 'app', expected: ['label', 'name'], @@ -1528,6 +1625,19 @@ const REACHABILITY_CORPUS: Array<{ label: string; stack: Record }, { label: 'role-word (identifier)', stack: { objects: [objectFixture({ name: 'user_role', sharingModel: 'private' })] } }, { label: 'role-word (label)', stack: { objects: [objectFixture({ name: 'user_duty', label: 'User Role', sharingModel: 'private' })] } }, + // [#18306] Not a new push site — the same two branches, reached through the + // field-group surface. It earns its place in THIS corpus for the other + // guarantee the corpus makes: that the keys the rule reads are keys an + // author can legally write, which is the claim `group.key` / `group.label` + // rests on. + { + label: 'role-word (field-group key)', + stack: { objects: [objectFixture({ name: 'user_duty', sharingModel: 'private', fieldGroups: [{ key: 'role_info', label: 'Assignment' }] })] }, + }, + { + label: 'role-word (field-group label)', + stack: { objects: [objectFixture({ name: 'user_duty', sharingModel: 'private', fieldGroups: [{ key: 'assignment', label: 'Account & Role' }] })] }, + }, { label: 'book-audience-unknown-set', stack: { books: [{ name: 'guide', label: 'Guide', slug: 'guide', groups: [], audience: { permissionSet: 'nobody_declares_this' } }] },