Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/30692-field-group-requires-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
'@objectstack/platform-objects': minor
---

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

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

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.

- **`@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`.
- **`@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`.
- **`@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.
18 changes: 18 additions & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ const result = ApiMethod.parse(data);
| **description** | `string` | optional | Optional description shown under the group header |
| **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). |
| **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'` |
| **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. |
| **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). |
| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
| **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. |
Expand Down Expand Up @@ -724,11 +725,28 @@ External datasource binding (ADR-0015)
| **description** | `string` | optional | Optional description shown under the group header |
| **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). |
| **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'` |
| **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. |
| **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). |
| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
| **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. |
| **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. |

### Allowed Values: `ObjectFieldGroup.requiresFeature`

* `twoFactor`
* `organization`
* `multiOrgEnabled`
* `degradedTenancy`
* `oidcProvider`
* `sso`
* `ssoEnforced`
* `deviceAuthorization`
* `admin`
* `phoneNumber`
* `cloudManaged`
* `phoneNumberOtp`
* `emailDelivery`


---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import { describe, expect, it } from 'vitest';
import { EXPRESSION_INVALID, runAuthoringRules } from './authoring-rules.js';
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
import { validateStackExpressions } from './validate-expressions.js';

/**
* The card's probe object, verbatim but for the predicate: `stage` sits in
Expand Down Expand Up @@ -165,3 +166,71 @@ describe('#26646 — the object door gives the build\'s field group `visibleWhen
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
});
});

/**
* #30692 — a field group's `requiresFeature` (ruling 6670372892 on #30210, row
* 4 A). The spec lowers it at parse into the group's `visibleWhen` as the
* flag's gate alone, or `(<authored>) && <gate>`, and both renderers bind the
* host's `features` root for that predicate. The root verdict accepts exactly
* that trailing gate; any other `features.*` read in a group predicate keeps
* the verdict it had.
*/
describe('#30692 — the build and the object door accept the gate a group\'s `requiresFeature` lowers to', () => {
const GATE = 'features.multiOrgEnabled != false';
const LOWERED = [
{ dialect: 'cel', source: GATE },
{ dialect: 'cel', source: `(record.stage == 'won') && ${GATE}` },
// The opt-in spelling of another flag of the same registry.
{ dialect: 'cel', source: '(record.stage == \'won\') && features.twoFactor == true' },
] as const;

for (const body of LOWERED) {
it(`⭐ CONTROL — the lowered \`${body.source}\` publishes clean, and the build agrees`, () => {
const result = gateObject(fxGroup(body));

expect(result.rulesRun).toContain('validateStackExpressions');
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
expect(buildFindings(fxGroup(body))).toEqual([]);
});
}

it('⭐ CONTROL — the unlowered key itself publishes clean (a door that judges the item before parse)', () => {
const item = { ...fxGroup(undefined), fieldGroups: [{ key: 'g1', label: 'G1', requiresFeature: 'multiOrgEnabled' }] };
expect(expressionFindings(gateObject(item).errors), dump(item)).toEqual([]);
expect(buildFindings(item)).toEqual([]);
});

const STILL_REFUSED = [
// A gate that is not the flag's canonical one.
'features.multiOrgEnabled == true',
// The gate first: not the lowering's shape.
`${GATE} && record.stage == 'won'`,
// The authored part reads another root the group does not bind.
`('org_admin' in current_user.positions) && ${GATE}`,
// Two groups, not one: the part before the gate is \`record.stage == 'a') && (features.organization != false\`.
`(record.stage == 'a') && (features.organization != false) && ${GATE}`,
// A flag outside the registry.
'(record.stage == \'won\') && features.notAFlag != false',
] as const;

for (const body of STILL_REFUSED) {
it(`⭐ LIT — \`${body}\` keeps its root verdict`, () => {
const errs = expressionFindings(gateObject(fxGroup(body)).errors);
expect(errs, dump(errs)).toHaveLength(1);
expect(errs[0]).toMatchObject({ severity: 'error', where: WHERE });
expect(errs[0]!.message).toMatch(/^`visibleWhen` reads `(features|current_user)`/);
// The build's issue quotes the whole predicate the group carries, not the
// part the root verdict judged.
const issues = validateStackExpressions({ objects: [fxGroup(body)] }).filter((i) => i.where === WHERE);
expect(issues.map((i) => i.source)).toEqual([body]);
expect(buildFindings(fxGroup(body))).toEqual(errs);
});
}

it('a hand-written `features` read on a group is pointed at `requiresFeature`', () => {
const errs = expressionFindings(gateObject(fxGroup('features.multiOrgEnabled == true')).errors);
expect(errs, dump(errs)).toHaveLength(1);
expect(errs[0]!.message).toContain("`requiresFeature: 'FLAG'`");
});
});
5 changes: 5 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4480,6 +4480,11 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
// from the flow's collected names and the root judge's `provable`, and
// its screen-field name set (Set methods) — never read off metadata.
'flowBare', 'screenFields',
// [#30692] A field group predicate's CEL text and the gate suffix a
// `requiresFeature` lowering appends (`withoutLoweredFeatureGate`).
// Their keys are String methods, never metadata keys; the predicate is
// read off the tabled `fieldGroup` receiver.
'loweredSource', 'loweredTail',
]);
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
});
Expand Down
61 changes: 57 additions & 4 deletions packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,12 @@ import type { FlowNodeParsed, FlowEdgeParsed, FlowTriggerKind } from '@objectsta
// shared refusal to prevent.
import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
import { referenceCarrierOf, referenceTargetOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data';
import { isStoredMetadataBodyObject, STORED_METADATA_BODY_PRESCRIPTION } from '@objectstack/spec/kernel';
import {
isStoredMetadataBodyObject,
STORED_METADATA_BODY_PRESCRIPTION,
PUBLIC_AUTH_FEATURE_NAMES,
featureGatePredicate,
} from '@objectstack/spec/kernel';
// [#22394] The acting user's declared members — the contract half of the option
// verdict's member allowlist for `current_user` and its ADR-0068 aliases. Read
// off the schema at first use, never restated here.
Expand Down Expand Up @@ -1023,6 +1028,43 @@ const FIELD_RULE_USER_ROOTS = ['current_user', 'user', 'ctx', 'os'] as const;
* and worded for it ({@link FIELD_RULE_SLOT_CONSEQUENCE}, {@link fieldRuleRootIssue}).
*/
const FIELD_GROUP_VISIBLE_WHEN_SLOT = 'field group visibleWhen';

/**
* [#30692] A field group's `visibleWhen` with the gate its `requiresFeature`
* lowered into it taken off, so the root verdict judges what the AUTHOR wrote.
*
* `fieldGroups[].requiresFeature` (`ObjectFieldGroupSchema`) is lowered at
* parse by the spec's `lowerRequiresFeature` into one of exactly two shapes —
* the gate alone, or `(<authored>) && <gate>` — where the gate is
* `featureGatePredicate(flag)` for a flag of `PUBLIC_AUTH_FEATURES`. Both
* renderers evaluate a group's predicate with the host's `features` root in
* scope (the form's section gate and the record page's `record:details`), so
* that gate is enforced where it is read; it is the ruled shape (#30210 row 4
* A, ruling 6670372892), and only it is accepted. A `features.*` read anywhere
* else in the predicate keeps its root verdict: this takes off the trailing
* gate the lowering appends, and nothing more.
*
* Returns `undefined` when the whole predicate is the gate (nothing authored
* left to judge), the authored part in the input's own spelling when the gate
* was composed onto one, and the input untouched otherwise — including when
* the part before the gate does not parse on its own, which is how a
* hand-written `(a) && (b) && <gate>` (whose "authored part" would be
* `a) && (b`) is told apart from the lowering's single parenthesised group.
*/
export function withoutLoweredFeatureGate(raw: unknown): unknown {
const loweredSource = celSourceOf(raw);
if (loweredSource === undefined) return raw;
for (const flag of PUBLIC_AUTH_FEATURE_NAMES) {
const gate = featureGatePredicate(flag);
if (loweredSource === gate) return undefined;
const loweredTail = `) && ${gate}`;
if (!loweredSource.startsWith('(') || !loweredSource.endsWith(loweredTail)) continue;
const authored = loweredSource.slice(1, loweredSource.length - loweredTail.length);
if (!collectCelRootIdentifiers(authored).ok) return raw;
return typeof raw === 'string' ? authored : { ...(raw as AnyRec), source: authored };
}
return raw;
}
/**
* The slot-agnostic clause — provably true for any slot, specific to none.
* Used where the honest answer is "not measured for this slot", never as a
Expand Down Expand Up @@ -1120,7 +1162,15 @@ export function fieldRuleRootIssue(
// [#26646] A field group has no options and is not a field: its user-root
// prescription keeps only the two answers that apply to it.
const userRoot = (FIELD_RULE_USER_ROOTS as readonly string[]).includes(root);
const prescription = userRoot && slot === FIELD_GROUP_VISIBLE_WHEN_SLOT
const prescription = root === 'features' && slot === FIELD_GROUP_VISIBLE_WHEN_SLOT
// [#30692] A field group has its own door for a deployment feature: the
// `requiresFeature` key, whose lowered gate this rule accepts.
? `To show the group only where a deployment feature is on, declare it on the group ` +
`instead — \`requiresFeature: 'FLAG'\`, the same flag names an action's \`requiresFeature\` ` +
`takes — and keep \`visibleWhen\` for record state; the gate it lowers to is the one ` +
`\`features\` read this predicate accepts. To gate the group on record state, rewrite the ` +
`predicate against \`record\`.`
: userRoot && slot === FIELD_GROUP_VISIBLE_WHEN_SLOT
? `To hide the group's fields by role, declare field-level security on a permission set ` +
`for each field in the group (\`fields: { 'OBJECT.FIELD': { readable: false } }\`), which ` +
`the server enforces. To gate the group on record state, rewrite the predicate against ` +
Expand Down Expand Up @@ -3830,10 +3880,13 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
const groupKey = typeof fieldGroup.key === 'string' ? fieldGroup.key : '?';
const where = `object '${objectName}' · field group '${groupKey}' visibleWhen`;
const raw: unknown = fieldGroup.visibleWhen;
const verdict = fieldRuleRootVerdict(FIELD_GROUP_VISIBLE_WHEN_SLOT, raw);
// [#30692] The root verdict judges the predicate without the gate a
// `requiresFeature` lowered into it ({@link withoutLoweredFeatureGate});
// every other check still reads the whole predicate.
const verdict = fieldRuleRootVerdict(FIELD_GROUP_VISIBLE_WHEN_SLOT, withoutLoweredFeatureGate(raw));
check(where, raw, objectName, 'record', verdict !== null);
if (verdict) {
issues.push({ where, message: verdict.message, source: verdict.source, severity: 'error' });
issues.push({ where, message: verdict.message, source: celSourceOf(raw) ?? verdict.source, severity: 'error' });
}
}
}
Expand Down
Loading
Loading