Skip to content

Commit 677d542

Browse files
feat(spec): a field group's requiresFeature hides the group where a deployment feature is off; Edit organization hides Parent Organization and Sort Order on one organization (#30810)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DKdMdBgsLj6ZtbKmEBFgfcw)_ Fixes #30692 Clause-②: yes Metadata-protocol: added Skills-docs: follow-up #30812 — skills/objectstack-data/references/examples-objects.md (its Field Groups section teaches `fieldGroups` and should name `visibleWhen` and `requiresFeature` beside `collapse`); kept out of this diff so it does not become a governed `skills/**` change ## 维护者速读 - 改了什么:字段分组新增 `requiresFeature`(与动作上的同名键同一套特性名),解析时降成该分组的 `visibleWhen` 并从输出中去掉;组织对象声明了四个字段分组,「上级组织」「排序」所在的「集团结构」组要求 `multiOrgEnabled`。 - 为什么改:单组织部署的「编辑组织」页一直显示这两个毫无作用的字段(裁定 6670372892,#30210 第 4 行 A)。现在只有多组织部署才看得到。 - 风险与回滚:表单和详情页现在按分组显示(标题:基本信息 / 品牌 / 配置 / 集团结构),字段顺序不变;隐藏只是界面层,API 照常读写这两个字段。回滚即撤本 PR。 - 先看:`packages/spec/src/data/object.zod.ts`(新键与降级 transform);`packages/lint/src/validate-expressions.ts` 的 `withoutLoweredFeatureGate`(lint 只放行降级出来的那一个门);`packages/platform-objects/src/identity/sys-organization.object.ts`(四个分组)。 - 两分钟验证:`pnpm --filter @objectstack/platform-objects exec vitest run src/identity/sys-organization-group-structure-gate.test.ts`,预期 4 条全绿:`multiOrgEnabled` 关时两字段不出现,开时出现。 - 需要你做的:无,席位落地。 ## What changes - **`@objectstack/spec`: `fieldGroups[].requiresFeature`.** The field-group twin of the action-level `requiresFeature`, from the same `PUBLIC_AUTH_FEATURES` vocabulary, so an unknown flag is refused at parse (`invalid_value` at `requiresFeature`). It is lowered at parse into the group's `visibleWhen` by the same helper the action key uses, `lowerRequiresFeature`, which now takes an optional third argument naming the slot (`'visible'` by default, `'visibleWhen'` here); its refusals name that slot. The slot is a type parameter and the input's constraint is keyed on it, so lowering an action into `'visibleWhen'`, or a field group into the default `'visible'`, fails to compile; the action call sites are unchanged. The key is stripped from the output. - **How the two combine** when an author writes both: AND, the author's predicate first and the gate last, exactly as for actions: `visibleWhen: "record.kind == 'child'"` + `requiresFeature: 'multiOrgEnabled'` parses to `(record.kind == 'child') && features.multiOrgEnabled != false`. A non-CEL `visibleWhen` refuses the pair, as for actions. - `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` books `sys_organization.fieldGroups.group_structure`; the `gatedInputs` path grammar admits `OBJECT.fieldGroups.KEY`, and the platform-objects completeness guard walks field groups in both directions. - `object.form.ts` offers the key on the `fieldGroups` repeater (label and help in en / zh-CN / ja-JP / es-ES). Liveness: `object/fieldGroups.requiresFeature` is `live`. - **`@objectstack/lint`: the lowered gate is accepted.** The field-group `visibleWhen` root verdict now judges the predicate with the trailing gate a `requiresFeature` lowers to taken off (`withoutLoweredFeatureGate`): the gate alone, or `(AUTHORED) && GATE` where the authored part parses on its own. Any other `features` read in a group predicate keeps its root verdict, and its message now points at `requiresFeature`. The finding still quotes the whole predicate. - **`@objectstack/platform-objects`: `sys_organization` declares `fieldGroups`.** Identity, Branding, Configuration, Group Structure, in the fields' own order, so no field moves; `id` / `created_at` / `updated_at` keep `group: 'System'`, which is not declared, so they render last in the ungrouped bucket as before. Group Structure (`parent_organization_id`, `sort_order`) carries `requiresFeature: 'multiOrgEnabled'`. Section labels are translated in zh-CN / ja-JP / es-ES. The ui is unchanged, as the ruling says: the form's section gate and the record page's `record:details` (`ui/packages/plugin-detail/src/renderers/record-details.tsx` `sectionPredicateVisible`) both evaluate a group's `visibleWhen` with `usePredicateScope()`, which carries the host's `features`. ## Tests - Reproduced on `main` (`1371b9d8`, the object before this PR, tests from this PR): `sys-organization-group-structure-gate.test.ts` 3 failed / 1 passed; with `multiOrgEnabled: false` the flat layout still renders `parent_organization_id`. - New pins: `packages/spec/src/data/field-group-requires-feature.test.ts` (lowered and stripped; an opt-in flag gets its own gate; unknown flag refused at `requiresFeature`; both-present AND-composition from a string and from an envelope; unchanged without the key; inside `ObjectSchema.create`; and two `@ts-expect-error` compile pins: an action lowered into `'visibleWhen'` and a field group lowered into the default slot), `packages/platform-objects/src/identity/sys-organization-group-structure-gate.test.ts` (off hides, on shows, empty `features` faults and hides, only that group gated, declaration order kept), and a `#30692` describe in the lint harness `runtime-gate.object-field-group-visibility-writes.test.ts` (three lowered shapes publish clean at the door and the build; the unlowered key is clean; five hand-written `features` shapes keep the verdict; the prescription names `requiresFeature`). - Full suites on the pre-merge head `ab3d3a94`: `@objectstack/spec` 818/818 files, 25833 tests; `@objectstack/lint` 195/195, 8226; `@objectstack/platform-objects` 117/117, 1585; the three typechecks exit 0; `check:generated` exit 0. <details><summary>Ablations (one per changed behaviour)</summary> - Spec lowering, through `dist/`: `scripts/ablation-replace.mjs` replaced the transform with one that keeps the group and plants `ABLATION_30692` (anchor 1 → 0, blob `47ddc173` → `c0bfefc3`); spec rebuilt; `ablation-dist-preflight.mjs` found the marker in 10 built files. Red: spec pin 4 failed / 2 passed; platform-objects 4 failed (the three group-structure cells and the guard's forward row `sys_organization.fieldGroups.group_structure is gated on multiOrgEnabled`). Restored to the HEAD blob with `git diff HEAD` empty; spec rebuilt; preflight `--absent` clean over 592 files and the tree; the same two files green, 118/118. - Lint peel (source-imported, no dist leg): the verdict call fed `raw` instead of `withoutLoweredFeatureGate(raw)` (anchor 1 → 0, blob `7b9a6ec1` → `85289f1e`). Red: the three lowered-shape CONTROL cells; restored to the HEAD blob, `git diff HEAD` empty. </details> ## Acceptance notes - The `features` root is not opened on group predicates generally (ruling row 4 B was not chosen): only the exact trailing gate the lowering emits is accepted. A hand-written predicate byte-identical to that lowered form is accepted too, because it is the same gate. - **Fault direction.** A group's `visibleWhen` is fail-closed, where an action's `visible` is fail-soft. Reading a key that `features` does not carry is a CEL fault (`No such key`), not `null`, so with `features = {}` the default-on gate faults and the group is hidden. The console holds `features = {}` until `/auth/config` resolves (`AppContent.tsx`), and keeps it if that fetch fails. So on a multi-organization deployment Group Structure is hidden for the pre-fetch window and appears when the config arrives; if the fetch fails it stays hidden for that session. `getPublicConfig()` in plugin-auth always publishes `multiOrgEnabled`. A host that never publishes `features` would hide the group everywhere. The semantics table in `public-auth-features.ts`, the field-group docblock and the changeset say so, and `sys-organization-group-structure-gate.test.ts` pins the empty-scope reading. - Row 4 C's help text ("Meaningful only where the deployment runs several organizations.") stays, and stays reachable: the fields still show wherever `multiOrgEnabled` is on. - Declaring the groups adds section headers to sys_organization's form and record page; the field order is unchanged. - `id`, `created_at` and `updated_at` keep their undeclared `group: 'System'` (the `field-group-undeclared` advisory fires for those three, where before it fired for all twelve fields); declaring a System group would surface `created_at` / `updated_at` on the record page, a change the card did not ask for. ## Verification Head `559cf504` (review follow-up on `24375ec4`; merges cleanly with `origin/main` `2038c56e`), with spec and platform-objects rebuilt: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 105 families on `559cf504`; all 105 exit 0, each code captured to a file before any pipe. `--ran`: 105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN. Its STALE TREE note names only `scripts/pm/**` fleet tooling that moved on main, none on this diff's surface. - `pnpm lint`, `pnpm check:cli-command-ids`, `pnpm check:adr-symbol-anchors`, `node scripts/check-i18n-bundles.mjs`: exit 0. `packages/spec` `check:generated` (incl. api-surface and test-typecheck): exit 0. - Suites (`vitest run` in each package): `@objectstack/spec` 908/908 files, 27374 passed + 1 todo; `@objectstack/lint` 195/195, 8235; `@objectstack/platform-objects` 117/117, 1590. - Ablation of the slot key: the other-slot exclusion in `WithRequiresFeature` replaced with an inert key; `tsc -p tsconfig.test.json` then reports both `@ts-expect-error` pins as unused (TS2578); restored from the saved copy, marker count 0. - Dogfood battery: n/a (no `!` in the title, no narrowing arm). - Open PRs on the surface: #30760 edits `packages/platform-objects/src/apps/translations/{en,es-ES,ja-JP,zh-CN}.ts`; this PR touches the `*.objects.generated.ts` and `*.metadata-forms.generated.ts` bundles beside them, not those files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cyCnzVPr9GuD38DdGt <!-- signed: domain:server#5 · os-elon-musk · session_016WV4cyvF7SP65frVFAYLv7 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ae82d05 commit 677d542

28 files changed

Lines changed: 630 additions & 48 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/platform-objects': minor
5+
---
6+
7+
feat(spec): a field group's `requiresFeature` hides the group where a deployment feature is off; Edit organization hides Parent Organization and Sort Order on a single-organization deployment
8+
9+
Clause-②: yes
10+
Metadata-protocol: added
11+
Skills-docs: follow-up #30812 — skills/objectstack-data/references/examples-objects.md (its Field Groups section teaches `fieldGroups` and should name `visibleWhen` and `requiresFeature` beside `collapse`); kept out of this diff so it does not become a governed (`skills/**`) change
12+
13+
On a deployment that runs one organization, Edit organization showed Parent Organization and Sort Order, two fields that order and group organizations against each other and so do nothing there. They are now hidden wherever the deployment does not run several organizations.
14+
15+
- **`@objectstack/spec`: `fieldGroups[].requiresFeature`.** A field group can name a deployment feature flag, from the same set an action's `requiresFeature` names (`PUBLIC_AUTH_FEATURES`), and a flag outside it is refused at parse. It is lowered at parse into the group's `visibleWhen`, exactly as the action key is lowered into `visible`, and the key is stripped from the output: `{ key: 'group_structure', label: 'Group Structure', requiresFeature: 'multiOrgEnabled' }` parses to a group whose `visibleWhen` is `features.multiOrgEnabled != false`. Beside an authored `visibleWhen` the two are AND-composed, the author's predicate first: `visibleWhen: "record.kind == 'child'"` gives `(record.kind == 'child') && features.multiOrgEnabled != false`. The form and the record page already evaluate a group's `visibleWhen` with the host's `features` in scope, so the gate holds on both. Like every group predicate it is presentation only: the fields stay readable and writable through the API. A group predicate is fail-closed where an action's `visible` is fail-soft: while the host's `features` does not carry the flag yet (the console's empty scope before `/auth/config` resolves, or after that fetch fails) the gate faults and the group stays hidden, even for a default-on flag, and it appears once the flag is published. `lowerRequiresFeature` takes an optional third argument, the slot it lowers into (`'visible'`, the default, or `'visibleWhen'`), and its refusals name that slot. `PUBLIC_AUTH_FEATURES` books `sys_organization.fieldGroups.group_structure` under `multiOrgEnabled`; a `gatedInputs` path may now name a field group as `OBJECT.fieldGroups.KEY`.
16+
- **`@objectstack/lint`: the lowered gate is accepted.** A field group's `visibleWhen` keeps its root verdict (it binds `record`, `previous` and `parent`), except for the one trailing gate a `requiresFeature` lowers to, which `os validate`, `os build` and the object save door now accept. Any other `features` read in a group predicate is refused as before, and the message now points at `requiresFeature`.
17+
- **`@objectstack/platform-objects`: `sys_organization` declares its field groups.** Identity, Branding, Configuration and Group Structure, in the fields' own order, so every field keeps its place; `id`, `created_at` and `updated_at` stay ungrouped and last, as before. Group Structure (Parent Organization, Sort Order) carries `requiresFeature: 'multiOrgEnabled'`. The two fields' help, which says they matter only with several organizations, is unchanged.

‎content/docs/references/data/object.mdx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ const result = ApiMethod.parse(data);
320320
| **description** | `string` | optional | Optional description shown under the group header |
321321
| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. It names a body table only (a FK declaring relatedList: 'primary', or a master_detail FK that sets inlineEdit and declares no relatedList): a group naming a side card or a list hidden by relatedList: false draws nothing, so it is refused where the child's fields are known (os validate / os build and the metadata write door), naming the FK and the remedy (set relatedList: 'primary' on it, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList). |
322322
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` |
323+
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Deployment feature flag gating this group, from the same set an action's `requiresFeature` names (e.g. 'multiOrgEnabled'). Lowered at parse time into the group's `visibleWhen` (AND-composed after an authored `visibleWhen`) and stripped from the output; the whole group is hidden where the flag is off. Presentation only: the fields stay readable through the API. |
323324
| **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). |
324325
| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
325326
| **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. |
@@ -724,11 +725,28 @@ External datasource binding (ADR-0015)
724725
| **description** | `string` | optional | Optional description shown under the group header |
725726
| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. It names a body table only (a FK declaring relatedList: 'primary', or a master_detail FK that sets inlineEdit and declares no relatedList): a group naming a side card or a list hidden by relatedList: false draws nothing, so it is refused where the child's fields are known (os validate / os build and the metadata write door), naming the FK and the remedy (set relatedList: 'primary' on it, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList). |
726727
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` |
728+
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| … +3 more>` | optional | Deployment feature flag gating this group, from the same set an action's `requiresFeature` names (e.g. 'multiOrgEnabled'). Lowered at parse time into the group's `visibleWhen` (AND-composed after an authored `visibleWhen`) and stripped from the output; the whole group is hidden where the flag is off. Presentation only: the fields stay readable through the API. |
727729
| **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). |
728730
| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
729731
| **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. |
730732
| **collapsed** | `boolean` | optional | [DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no `collapsible` and outranks `collapsible: false`; false → 'none', or 'expanded' beside `collapsible: true`. Use the `collapse` enum. |
731733

734+
### Allowed Values: `ObjectFieldGroup.requiresFeature`
735+
736+
* `twoFactor`
737+
* `organization`
738+
* `multiOrgEnabled`
739+
* `degradedTenancy`
740+
* `oidcProvider`
741+
* `sso`
742+
* `ssoEnforced`
743+
* `deviceAuthorization`
744+
* `admin`
745+
* `phoneNumber`
746+
* `cloudManaged`
747+
* `phoneNumberOtp`
748+
* `emailDelivery`
749+
732750

733751
---
734752

‎packages/lint/src/runtime-gate.object-field-group-visibility-writes.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import { describe, expect, it } from 'vitest';
3131
import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js';
3232
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
33+
import { validateStackExpressions } from './validate-expressions.js';
3334

3435
/**
3536
* The card's probe object, verbatim but for the predicate: `stage` sits in
@@ -165,3 +166,71 @@ describe('#26646 — the object door gives the build\'s field group `visibleWhen
165166
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
166167
});
167168
});
169+
170+
/**
171+
* #30692 — a field group's `requiresFeature` (ruling 6670372892 on #30210, row
172+
* 4 A). The spec lowers it at parse into the group's `visibleWhen` as the
173+
* flag's gate alone, or `(<authored>) && <gate>`, and both renderers bind the
174+
* host's `features` root for that predicate. The root verdict accepts exactly
175+
* that trailing gate; any other `features.*` read in a group predicate keeps
176+
* the verdict it had.
177+
*/
178+
describe('#30692 — the build and the object door accept the gate a group\'s `requiresFeature` lowers to', () => {
179+
const GATE = 'features.multiOrgEnabled != false';
180+
const LOWERED = [
181+
{ dialect: 'cel', source: GATE },
182+
{ dialect: 'cel', source: `(record.stage == 'won') && ${GATE}` },
183+
// The opt-in spelling of another flag of the same registry.
184+
{ dialect: 'cel', source: '(record.stage == \'won\') && features.twoFactor == true' },
185+
] as const;
186+
187+
for (const body of LOWERED) {
188+
it(`⭐ CONTROL — the lowered \`${body.source}\` publishes clean, and the build agrees`, () => {
189+
const result = gateObject(fxGroup(body));
190+
191+
expect(result.rulesRun).toContain('validateStackExpressions');
192+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
193+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
194+
expect(buildFindings(fxGroup(body))).toEqual([]);
195+
});
196+
}
197+
198+
it('⭐ CONTROL — the unlowered key itself publishes clean (a door that judges the item before parse)', () => {
199+
const item = { ...fxGroup(undefined), fieldGroups: [{ key: 'g1', label: 'G1', requiresFeature: 'multiOrgEnabled' }] };
200+
expect(expressionFindings(gateObject(item).errors), dump(item)).toEqual([]);
201+
expect(buildFindings(item)).toEqual([]);
202+
});
203+
204+
const STILL_REFUSED = [
205+
// A gate that is not the flag's canonical one.
206+
'features.multiOrgEnabled == true',
207+
// The gate first: not the lowering's shape.
208+
`${GATE} && record.stage == 'won'`,
209+
// The authored part reads another root the group does not bind.
210+
`('org_admin' in current_user.positions) && ${GATE}`,
211+
// Two groups, not one: the part before the gate is \`record.stage == 'a') && (features.organization != false\`.
212+
`(record.stage == 'a') && (features.organization != false) && ${GATE}`,
213+
// A flag outside the registry.
214+
'(record.stage == \'won\') && features.notAFlag != false',
215+
] as const;
216+
217+
for (const body of STILL_REFUSED) {
218+
it(`⭐ LIT — \`${body}\` keeps its root verdict`, () => {
219+
const errs = expressionFindings(gateObject(fxGroup(body)).errors);
220+
expect(errs, dump(errs)).toHaveLength(1);
221+
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE });
222+
expect(errs[0]!.message).toMatch(/^`visibleWhen` reads `(features|current_user)`/);
223+
// The build's issue quotes the whole predicate the group carries, not the
224+
// part the root verdict judged.
225+
const issues = validateStackExpressions({ objects: [fxGroup(body)] }).filter((i) => i.where === WHERE);
226+
expect(issues.map((i) => i.source)).toEqual([body]);
227+
expect(buildFindings(fxGroup(body))).toEqual(errs);
228+
});
229+
}
230+
231+
it('a hand-written `features` read on a group is pointed at `requiresFeature`', () => {
232+
const errs = expressionFindings(gateObject(fxGroup('features.multiOrgEnabled == true')).errors);
233+
expect(errs, dump(errs)).toHaveLength(1);
234+
expect(errs[0]!.message).toContain("`requiresFeature: 'FLAG'`");
235+
});
236+
});

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4480,6 +4480,11 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
44804480
// from the flow's collected names and the root judge's `provable`, and
44814481
// its screen-field name set (Set methods) — never read off metadata.
44824482
'flowBare', 'screenFields',
4483+
// [#30692] A field group predicate's CEL text and the gate suffix a
4484+
// `requiresFeature` lowering appends (`withoutLoweredFeatureGate`).
4485+
// Their keys are String methods, never metadata keys; the predicate is
4486+
// read off the tabled `fieldGroup` receiver.
4487+
'loweredSource', 'loweredTail',
44834488
]);
44844489
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
44854490
});

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,12 @@ import type { FlowNodeParsed, FlowEdgeParsed, FlowTriggerKind } from '@objectsta
135135
// shared refusal to prevent.
136136
import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
137137
import { referenceCarrierOf, referenceTargetOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data';
138-
import { isStoredMetadataBodyObject, STORED_METADATA_BODY_PRESCRIPTION } from '@objectstack/spec/kernel';
138+
import {
139+
isStoredMetadataBodyObject,
140+
STORED_METADATA_BODY_PRESCRIPTION,
141+
PUBLIC_AUTH_FEATURE_NAMES,
142+
featureGatePredicate,
143+
} from '@objectstack/spec/kernel';
139144
// [#22394] The acting user's declared members — the contract half of the option
140145
// verdict's member allowlist for `current_user` and its ADR-0068 aliases. Read
141146
// off the schema at first use, never restated here.
@@ -1023,6 +1028,43 @@ const FIELD_RULE_USER_ROOTS = ['current_user', 'user', 'ctx', 'os'] as const;
10231028
* and worded for it ({@link FIELD_RULE_SLOT_CONSEQUENCE}, {@link fieldRuleRootIssue}).
10241029
*/
10251030
const FIELD_GROUP_VISIBLE_WHEN_SLOT = 'field group visibleWhen';
1031+
1032+
/**
1033+
* [#30692] A field group's `visibleWhen` with the gate its `requiresFeature`
1034+
* lowered into it taken off, so the root verdict judges what the AUTHOR wrote.
1035+
*
1036+
* `fieldGroups[].requiresFeature` (`ObjectFieldGroupSchema`) is lowered at
1037+
* parse by the spec's `lowerRequiresFeature` into one of exactly two shapes —
1038+
* the gate alone, or `(<authored>) && <gate>` — where the gate is
1039+
* `featureGatePredicate(flag)` for a flag of `PUBLIC_AUTH_FEATURES`. Both
1040+
* renderers evaluate a group's predicate with the host's `features` root in
1041+
* scope (the form's section gate and the record page's `record:details`), so
1042+
* that gate is enforced where it is read; it is the ruled shape (#30210 row 4
1043+
* A, ruling 6670372892), and only it is accepted. A `features.*` read anywhere
1044+
* else in the predicate keeps its root verdict: this takes off the trailing
1045+
* gate the lowering appends, and nothing more.
1046+
*
1047+
* Returns `undefined` when the whole predicate is the gate (nothing authored
1048+
* left to judge), the authored part in the input's own spelling when the gate
1049+
* was composed onto one, and the input untouched otherwise — including when
1050+
* the part before the gate does not parse on its own, which is how a
1051+
* hand-written `(a) && (b) && <gate>` (whose "authored part" would be
1052+
* `a) && (b`) is told apart from the lowering's single parenthesised group.
1053+
*/
1054+
export function withoutLoweredFeatureGate(raw: unknown): unknown {
1055+
const loweredSource = celSourceOf(raw);
1056+
if (loweredSource === undefined) return raw;
1057+
for (const flag of PUBLIC_AUTH_FEATURE_NAMES) {
1058+
const gate = featureGatePredicate(flag);
1059+
if (loweredSource === gate) return undefined;
1060+
const loweredTail = `) && ${gate}`;
1061+
if (!loweredSource.startsWith('(') || !loweredSource.endsWith(loweredTail)) continue;
1062+
const authored = loweredSource.slice(1, loweredSource.length - loweredTail.length);
1063+
if (!collectCelRootIdentifiers(authored).ok) return raw;
1064+
return typeof raw === 'string' ? authored : { ...(raw as AnyRec), source: authored };
1065+
}
1066+
return raw;
1067+
}
10261068
/**
10271069
* The slot-agnostic clause — provably true for any slot, specific to none.
10281070
* Used where the honest answer is "not measured for this slot", never as a
@@ -1120,7 +1162,15 @@ export function fieldRuleRootIssue(
11201162
// [#26646] A field group has no options and is not a field: its user-root
11211163
// prescription keeps only the two answers that apply to it.
11221164
const userRoot = (FIELD_RULE_USER_ROOTS as readonly string[]).includes(root);
1123-
const prescription = userRoot && slot === FIELD_GROUP_VISIBLE_WHEN_SLOT
1165+
const prescription = root === 'features' && slot === FIELD_GROUP_VISIBLE_WHEN_SLOT
1166+
// [#30692] A field group has its own door for a deployment feature: the
1167+
// `requiresFeature` key, whose lowered gate this rule accepts.
1168+
? `To show the group only where a deployment feature is on, declare it on the group ` +
1169+
`instead — \`requiresFeature: 'FLAG'\`, the same flag names an action's \`requiresFeature\` ` +
1170+
`takes — and keep \`visibleWhen\` for record state; the gate it lowers to is the one ` +
1171+
`\`features\` read this predicate accepts. To gate the group on record state, rewrite the ` +
1172+
`predicate against \`record\`.`
1173+
: userRoot && slot === FIELD_GROUP_VISIBLE_WHEN_SLOT
11241174
? `To hide the group's fields by role, declare field-level security on a permission set ` +
11251175
`for each field in the group (\`fields: { 'OBJECT.FIELD': { readable: false } }\`), which ` +
11261176
`the server enforces. To gate the group on record state, rewrite the predicate against ` +
@@ -3830,10 +3880,13 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
38303880
const groupKey = typeof fieldGroup.key === 'string' ? fieldGroup.key : '?';
38313881
const where = `object '${objectName}' · field group '${groupKey}' visibleWhen`;
38323882
const raw: unknown = fieldGroup.visibleWhen;
3833-
const verdict = fieldRuleRootVerdict(FIELD_GROUP_VISIBLE_WHEN_SLOT, raw);
3883+
// [#30692] The root verdict judges the predicate without the gate a
3884+
// `requiresFeature` lowered into it ({@link withoutLoweredFeatureGate});
3885+
// every other check still reads the whole predicate.
3886+
const verdict = fieldRuleRootVerdict(FIELD_GROUP_VISIBLE_WHEN_SLOT, withoutLoweredFeatureGate(raw));
38343887
check(where, raw, objectName, 'record', verdict !== null);
38353888
if (verdict) {
3836-
issues.push({ where, message: verdict.message, source: verdict.source, severity: 'error' });
3889+
issues.push({ where, message: verdict.message, source: celSourceOf(raw) ?? verdict.source, severity: 'error' });
38373890
}
38383891
}
38393892
}

0 commit comments

Comments
 (0)