diff --git a/.changeset/30692-field-group-requires-feature.md b/.changeset/30692-field-group-requires-feature.md new file mode 100644 index 00000000000..99bdb906c5d --- /dev/null +++ b/.changeset/30692-field-group-requires-feature.md @@ -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. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 02364ae26e3..7474626810e 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -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. | @@ -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` + --- diff --git a/packages/lint/src/runtime-gate.object-field-group-visibility-writes.test.ts b/packages/lint/src/runtime-gate.object-field-group-visibility-writes.test.ts index afbd66427dc..9a39b1ca1c8 100644 --- a/packages/lint/src/runtime-gate.object-field-group-visibility-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-field-group-visibility-writes.test.ts @@ -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 @@ -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 `() && `, 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'`"); + }); +}); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index bc84cfa2feb..fa775112d94 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -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([]); }); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index d6910463119..7b9a6ec1e5b 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -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. @@ -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 `() && ` — 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) && ` (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 @@ -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 ` + @@ -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' }); } } } diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index a5fc0aa16ff..42ec5749dd6 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -112,6 +112,10 @@ export const enMetadataForms: NonNullable = { label: "Visible When", helpText: "CEL predicate over the record (e.g. record.type == 'invoice') — the entry form shows the whole group, header included, only while it is TRUE." }, + "fieldGroups.requiresFeature": { + label: "Requires Feature", + helpText: "Deployment feature the group needs (e.g. multiOrgEnabled): where it is off, the entry form and the record detail page hide the whole group. Combined with Visible When, the group shows only while both hold. The fields stay readable through the API." + }, fields: { label: "Fields", helpText: "Add the columns this object will store" diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 62710dbeabd..d6c4a4fde83 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -634,6 +634,20 @@ export const enObjects: NonNullable = { confirmText: "Leave this organization? You will lose access to its environments and members.", successMessage: "You have left the organization" } + }, + _sections: { + identity: { + label: "Identity" + }, + branding: { + label: "Branding" + }, + configuration: { + label: "Configuration" + }, + group_structure: { + label: "Group Structure" + } } }, sys_member: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 290d039e815..b630a1e87e6 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -112,6 +112,10 @@ export const esESMetadataForms: NonNullable = label: "Condición de visibilidad", helpText: "Predicado CEL sobre el registro (p. ej., record.type == 'invoice'): el formulario de entrada muestra el grupo entero, encabezado incluido, solo mientras sea TRUE." }, + "fieldGroups.requiresFeature": { + label: "Requiere función", + helpText: "Función del despliegue que necesita el grupo (p. ej., multiOrgEnabled): donde está desactivada, el formulario de entrada y la página de detalle del registro ocultan el grupo entero. Junto con Condición de visibilidad, el grupo solo se muestra mientras se cumplen ambas. Los campos siguen siendo legibles a través de la API." + }, fields: { label: "Campos", helpText: "Añade las columnas que almacenará este objeto" diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 41840ec2003..362cb7c7ff0 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -634,6 +634,20 @@ export const esESObjects: NonNullable = { confirmText: "¿Abandonar esta organización? Perderá el acceso a sus entornos y miembros.", successMessage: "Ha abandonado la organización" } + }, + _sections: { + identity: { + label: "Identidad" + }, + branding: { + label: "Marca" + }, + configuration: { + label: "Configuración" + }, + group_structure: { + label: "Estructura del grupo" + } } }, sys_member: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 64ed475266f..131bce9c932 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -112,6 +112,10 @@ export const jaJPMetadataForms: NonNullable = label: "表示条件", helpText: "レコードに対する CEL 述語(例:record.type == 'invoice')。TRUE の間だけ、入力フォームに見出しを含むグループ全体が表示されます。" }, + "fieldGroups.requiresFeature": { + label: "必要な機能", + helpText: "このグループに必要なデプロイ機能(例: multiOrgEnabled)。オフのデプロイでは、入力フォームとレコード詳細ページでグループ全体が非表示になります。「表示条件」と併用すると、両方が成り立つときだけ表示されます。フィールドは API から引き続き読み取れます。" + }, fields: { label: "フィールド", helpText: "このオブジェクトが保存する列を追加" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 43868fee53a..39fb7fec74d 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -634,6 +634,20 @@ export const jaJPObjects: NonNullable = { confirmText: "この組織から退出しますか?この組織の環境とメンバーへのアクセスを失います。", successMessage: "組織から退出しました" } + }, + _sections: { + identity: { + label: "基本情報" + }, + branding: { + label: "ブランディング" + }, + configuration: { + label: "設定" + }, + group_structure: { + label: "グループ構成" + } } }, sys_member: { diff --git a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts index a85ffc63dcf..d401ba56f2b 100644 --- a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts @@ -978,7 +978,10 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape', // `fieldGroups` repeater its `relatedList` sub-row — four leaves (two // labels, two help texts), authored in all three locales, so `openEchoes` // stays at zero. - expect(OPEN_LEAVES.length).toBe(122); + // 124 since #30692 gave the `fieldGroups` repeater its `requiresFeature` + // sub-row (the deployment feature a group needs) — label and help text, + // authored in all three locales, so `openEchoes` stays at zero. + expect(OPEN_LEAVES.length).toBe(124); expect(PANEL_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(false); expect(OPEN_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(true); for (const path of ['name', 'label', 'fields', 'fields.valueDomain', 'fields.deleteBehavior', 'fields.expression']) { diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 7fe64f64334..4ea4e8df0e6 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1266,7 +1266,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // `selection` / `pagination` / `userFilters` (with the `fields` repeater's // six row properties) / `appearance` / `userActions` / `navigation` / // `sharing` composites — thirty row labels, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(732); + // 733 since #30692: the object form's `fieldGroups` repeater offers + // `requiresFeature` (the deployment feature a group needs) — one row + // label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(733); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index ab03233a6fe..514de43adb9 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -112,6 +112,10 @@ export const zhCNMetadataForms: NonNullable = label: "可见条件", helpText: "基于记录的 CEL 谓词(例如 record.type == 'invoice')——仅当其为 TRUE 时,录入表单才显示整个分组(包括标题)。" }, + "fieldGroups.requiresFeature": { + label: "所需功能", + helpText: "该分组需要的部署功能(例如 multiOrgEnabled):功能关闭时,录入表单和记录详情页隐藏整个分组。与「可见条件」同时设置时,两者都成立才显示该分组。分组中的字段仍可通过 API 读取。" + }, fields: { label: "字段", helpText: "添加该对象将存储的列" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index a2593d6c820..ff5ab7c0cb6 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -634,6 +634,20 @@ export const zhCNObjects: NonNullable = { confirmText: "要退出该组织吗?退出后你将失去对其环境和成员的访问权限。", successMessage: "你已退出该组织" } + }, + _sections: { + identity: { + label: "基本信息" + }, + branding: { + label: "品牌" + }, + configuration: { + label: "配置" + }, + group_structure: { + label: "集团结构" + } } }, sys_member: { diff --git a/packages/platform-objects/src/feature-gate-guard.test.ts b/packages/platform-objects/src/feature-gate-guard.test.ts index 61a6cf3143d..c47372926a6 100644 --- a/packages/platform-objects/src/feature-gate-guard.test.ts +++ b/packages/platform-objects/src/feature-gate-guard.test.ts @@ -26,6 +26,10 @@ * only checks it is booked, and its verdicts are pinned in * `identity/sys-user-create-user-email-required.test.ts`. * + * A field group's `visibleWhen` is the third site (#30692): a group declaring + * `requiresFeature` is lowered there, booked as `.fieldGroups.`, + * and walked in both directions like an action. + * * Scope notes: objects that moved to capability plugins per ADR-0029 K2 * (plugin-security / plugin-sharing / plugin-audit / service-realtime / * plugin-webhooks) are outside this package and this guard; none of them @@ -48,7 +52,8 @@ import * as system from './system/index.js'; type AnyParam = { name?: string; field?: string; visible?: { source?: string } }; type AnyAction = { name?: string; visible?: { source?: string }; params?: AnyParam[] }; -type AnyObject = { name?: string; actions?: AnyAction[] }; +type AnyGroup = { key?: string; visibleWhen?: { source?: string } }; +type AnyObject = { name?: string; actions?: AnyAction[]; fieldGroups?: AnyGroup[] }; // Every exported platform object, keyed by machine name (object.name). const objectsByName = new Map(); @@ -138,8 +143,20 @@ function carriesGateConjunct(source: string, gate: string): boolean { return conjuncts(source).some((conjunct) => conjunct === gate); } -/** Resolve `.actions.[.params.]` to its `visible`. */ +/** + * Resolve `.actions.[.params.]` to its `visible`, + * or `.fieldGroups.` to that group's `visibleWhen`. + */ function resolveGatedInput(path: string): { visibleSource: string | undefined } { + const groupMatch = /^([a-z0-9_]+)\.fieldGroups\.([a-z0-9_]+)$/.exec(path); + if (groupMatch) { + const [, objectName, groupKey] = groupMatch; + const object = objectsByName.get(objectName); + expect(object, `object exists: ${path}`).toBeDefined(); + const group = (object!.fieldGroups ?? []).find((g) => g.key === groupKey); + expect(group, `field group exists: ${path}`).toBeDefined(); + return { visibleSource: group!.visibleWhen?.source }; + } const match = /^([a-z0-9_]+)\.actions\.([a-z0-9_]+)(?:\.params\.([A-Za-z0-9_]+))?$/.exec(path); expect(match, `path grammar: ${path}`).not.toBeNull(); const [, objectName, actionName, paramName] = match!; @@ -219,6 +236,11 @@ describe('feature-gate completeness guard (#2874)', () => { } } } + for (const group of object.fieldGroups ?? []) { + for (const m of (group.visibleWhen?.source ?? '').matchAll(/features\.([A-Za-z0-9_]+)/g)) { + referencedInputs.push([`${objectName}.fieldGroups.${group.key}`, m[1]]); + } + } } it('finds the gated surface (guards the walker itself)', () => { diff --git a/packages/platform-objects/src/identity/sys-organization-group-structure-gate.test.ts b/packages/platform-objects/src/identity/sys-organization-group-structure-gate.test.ts new file mode 100644 index 00000000000..a1c7cedb116 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-organization-group-structure-gate.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// On a deployment that runs one organization, Parent Organization and Sort +// Order order and group organizations against nothing, so Edit organization +// hides them there (#30692, ruling 6670372892 on #30210, row 4 A). The two +// fields sit in the object's `group_structure` field group, declared with +// `requiresFeature: 'multiOrgEnabled'`, which the spec lowers into the group's +// `visibleWhen` at parse. Both renderers evaluate that predicate with the +// host's `features` root in scope (the form's section gate and the record +// page's `record:details`), fail-closed. +import { describe, expect, it } from 'vitest'; +import { celEngine } from '@objectstack/formula'; +import { deriveFieldGroupLayout } from '@objectstack/spec/data'; +import { featureGatePredicate } from '@objectstack/spec/kernel'; +import { SysOrganization } from './sys-organization.object.js'; + +type Group = { key: string; visibleWhen?: { dialect?: string; source?: string }; requiresFeature?: unknown }; +const groups = (SysOrganization.fieldGroups ?? []) as Group[]; +// `null` = grouping does not apply (no declared groups), and both renderers +// fall back to a flat layout of every field: what a deployment saw before the +// groups were declared. +const layout = deriveFieldGroupLayout(SysOrganization as Parameters[0]) + ?? [{ fields: Object.keys(SysOrganization.fields), visibleWhen: undefined }]; + +/** + * The section-gate rule both renderers apply: no predicate shows the section, + * a predicate shows it only when it answers TRUE, and a fault hides it. + */ +function sectionShown(visibleWhen: Group['visibleWhen'], features: Record): boolean { + if (!visibleWhen?.source) return true; + const r = celEngine.evaluate({ dialect: 'cel', source: visibleWhen.source }, { record: {}, extra: { features } }); + return r.ok && r.value === true; +} + +/** Field names the layout renders for this deployment, in render order. */ +function renderedFields(features: Record): string[] { + return layout.filter((s) => sectionShown(s.visibleWhen as Group['visibleWhen'], features)).flatMap((s) => s.fields); +} + +describe('sys_organization: Parent Organization and Sort Order hide on a single-organization deployment', () => { + it('both fields are in one field group that the multiOrgEnabled gate guards', () => { + const fields = SysOrganization.fields as Record; + expect(fields.parent_organization_id?.group).toBe('group_structure'); + expect(fields.sort_order?.group).toBe('group_structure'); + const group = groups.find((g) => g.key === 'group_structure'); + expect(group?.visibleWhen).toEqual({ dialect: 'cel', source: featureGatePredicate('multiOrgEnabled') }); + // Lowered and stripped: no consumer sees the sugar key. + expect(group && 'requiresFeature' in group).toBe(false); + }); + + it('multiOrgEnabled off: the group is hidden', () => { + const shown = renderedFields({ multiOrgEnabled: false }); + expect(shown).not.toContain('parent_organization_id'); + expect(shown).not.toContain('sort_order'); + expect(shown).toContain('name'); + }); + + it('multiOrgEnabled on: the group is shown', () => { + const shown = renderedFields({ multiOrgEnabled: true }); + expect(shown).toContain('parent_organization_id'); + expect(shown).toContain('sort_order'); + }); + + // The group slot's fault direction (#30692 review). The console holds + // `features = {}` until /auth/config resolves, and keeps it empty if that + // fetch fails. Reading a key `features` does not carry is a CEL fault, not + // `null`, so the default-on gate does not answer "visible" there the way an + // action's fail-soft `visible` does: the section gate is fail-closed, and the + // group stays hidden until the flag is published, on every deployment. + it('features not yet published: the gate faults and the group is hidden (fail-closed)', () => { + const r = celEngine.evaluate( + { dialect: 'cel', source: featureGatePredicate('multiOrgEnabled') }, + { record: {}, extra: { features: {} } }, + ); + expect(r.ok).toBe(false); + const shown = renderedFields({}); + expect(shown).not.toContain('parent_organization_id'); + expect(shown).not.toContain('sort_order'); + expect(shown).toContain('name'); + }); + + it('no other group is gated, and the fields keep their declaration order', () => { + expect(groups.filter((g) => g.visibleWhen !== undefined).map((g) => g.key)).toEqual(['group_structure']); + const declared = Object.keys(SysOrganization.fields).filter((name) => + renderedFields({ multiOrgEnabled: true }).includes(name), + ); + expect(renderedFields({ multiOrgEnabled: true })).toEqual(declared); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index b6c0cbdf7b6..d4c1770418e 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -332,6 +332,26 @@ export const SysOrganization = ObjectSchema.create({ using: 'id == current_user.organization_id', }, + // Declared in the fields' own order, so every field keeps its place. The + // System trio (`id`, `created_at`, `updated_at`) stays in no declared group + // and renders last, as before. `group_structure` carries the ADR-0105 D6 + // reporting fields, which order and group organizations against each other + // and so do nothing on a deployment that runs one organization: the group is + // gated on `multiOrgEnabled` (lowered at parse into its `visibleWhen`), and + // the form and the record page both hide it where the flag is off (ruling + // 6670372892 on #30210, row 4 A). Presentation only — the fields stay + // readable and writable through the API, and their help still says so. + // A group predicate is fail-closed, so the group is also hidden while the + // console's `features` is still `{}` (before /auth/config resolves, or if + // that fetch fails): on a multi-organization deployment it appears once the + // config arrives. + fieldGroups: [ + { key: 'identity', label: 'Identity' }, + { key: 'branding', label: 'Branding' }, + { key: 'configuration', label: 'Configuration' }, + { key: 'group_structure', label: 'Group Structure', requiresFeature: 'multiOrgEnabled' }, + ], + fields: { // ── Identity ───────────────────────────────────────────────── // ADR-0092 D4 — with the generic edit affordance open (#15873), every @@ -360,7 +380,7 @@ export const SysOrganization = ObjectSchema.create({ description: 'The organization\'s own name. Change it with Rename Organization at the top of this record. ' + 'The product name the console shows is a different setting, set in Settings → Branding.', - group: 'Identity', + group: 'identity', }), slug: Field.text({ @@ -370,7 +390,7 @@ export const SysOrganization = ObjectSchema.create({ searchable: true, maxLength: 255, description: 'URL-friendly identifier', - group: 'Identity', + group: 'identity', }), // ── Branding ───────────────────────────────────────────────── @@ -378,7 +398,7 @@ export const SysOrganization = ObjectSchema.create({ label: 'Logo', required: false, readonly: true, - group: 'Branding', + group: 'branding', }), // ── Configuration ──────────────────────────────────────────── @@ -389,7 +409,7 @@ export const SysOrganization = ObjectSchema.create({ required: false, readonly: true, description: 'JSON-serialized organization metadata', - group: 'Configuration', + group: 'configuration', }), // ADR-0069 D3 — per-org MFA tightening above the global floor. When true, @@ -402,7 +422,7 @@ export const SysOrganization = ObjectSchema.create({ label: 'Require Multi-Factor Auth', required: false, defaultValue: false, - group: 'Configuration', + group: 'configuration', description: 'Turn on to require every member of this organization to set up an authenticator app before they can access data. ' + 'Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, ' + @@ -458,7 +478,7 @@ export const SysOrganization = ObjectSchema.create({ valueDomain: 'iana_time_zone', description: 'IANA time zone (e.g. UTC, Asia/Shanghai), kept as the root default for business units without a zone of their own. It does not change any date calculation today: formulas, reports and displayed dates use Settings → Localization → Default timezone.', - group: 'Configuration', + group: 'configuration', }), // ── Group structure (ADR-0105 D6) ──────────────────────────── @@ -484,7 +504,7 @@ export const SysOrganization = ObjectSchema.create({ parent_organization_id: Field.lookup('sys_organization', { label: 'Parent Organization', required: false, - group: 'Group Structure', + group: 'group_structure', description: 'Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference. Meaningful only where the deployment runs several organizations.', }), @@ -492,7 +512,7 @@ export const SysOrganization = ObjectSchema.create({ sort_order: Field.number({ label: 'Sort Order', required: false, - group: 'Group Structure', + group: 'group_structure', description: 'Display order among sibling organizations. Presentation only. Meaningful only where the deployment runs several organizations.', }), diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index e9f4c6ab694..44c8085159c 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -377,6 +377,7 @@ "RealTimeNotificationConfigSchema (const)", "RequiredAction (type)", "RequiredActionSchema (const)", + "RequiresFeatureSlot (type)", "ResolvedDependency (type)", "ResolvedDependencySchema (const)", "ResolvedPackageDependency (type)", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index a7078d83037..963a00daca7 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -713,6 +713,7 @@ "data/ObjectFieldGroup:key", "data/ObjectFieldGroup:label", "data/ObjectFieldGroup:relatedList", + "data/ObjectFieldGroup:requiresFeature", "data/ObjectFieldGroup:visibleWhen", "data/PerOperationRequiredPermissions:create", "data/PerOperationRequiredPermissions:delete", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index de42d11a04e..dcdd56935be 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -376,6 +376,7 @@ "RealTimeNotificationConfigSchema": "src/kernel/events/integrations.zod.ts#RealTimeNotificationConfigSchema (const)", "RequiredAction": "src/kernel/dependency-resolution.zod.ts#RequiredAction (type)", "RequiredActionSchema": "src/kernel/dependency-resolution.zod.ts#RequiredActionSchema (const)", + "RequiresFeatureSlot": "src/kernel/public-auth-features.ts#RequiresFeatureSlot (type)", "ResolvedDependency": "src/kernel/dependency-resolution.zod.ts#ResolvedDependency (type)", "ResolvedDependencySchema": "src/kernel/dependency-resolution.zod.ts#ResolvedDependencySchema (const)", "ResolvedPackageDependency": "src/kernel/plugin-security.zod.ts#ResolvedPackageDependency (type)", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index c1c7445867d..5741fa5121d 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -110,6 +110,13 @@ "evidence": "packages/spec/src/data/field-group-layout.ts#readGroup (carried verbatim, bare CEL or envelope); ui/packages/plugin-form/src/fieldGroups.ts#deriveFieldGroupSections (onto the form section, with `visibleWhenFault: 'hidden'`); ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts#deriveFieldGroupDetailEntries (beside the detail section for `record:details` to evaluate per record)", "note": "ADR-0089 group predicate, fail-closed: FALSE hides the whole group, header included." }, + "requiresFeature": { + "status": "live", + "verifiedAt": "2026-11-06", + "evidenceScope": "in-repo", + "evidence": "packages/spec/src/data/object.zod.ts#ObjectFieldGroupSchema (the key, and the transform that lowers it); packages/spec/src/kernel/public-auth-features.ts#lowerRequiresFeature (lowered at parse into the group's `visibleWhen`, stripped from the output)", + "note": "#30692, ruling 6670372892 on #30210 (row 4 A). Sugar for the flag's gate on `visibleWhen` (AND-composed after an authored predicate), from the same `PUBLIC_AUTH_FEATURES` set an action's `requiresFeature` names; no consumer ever reads the key itself. It is enforced where `visibleWhen` is (the row above): the form section gate and the record page's `record:details` evaluate a group predicate with the host's `features` root in scope (`usePredicateScope`), so the gate holds on both." + }, "collapse": { "status": "live", "verifiedAt": "2026-10-28", diff --git a/packages/spec/liveness/state-counts/object.md b/packages/spec/liveness/state-counts/object.md index 45d91fbf326..4fde1fe4056 100644 --- a/packages/spec/liveness/state-counts/object.md +++ b/packages/spec/liveness/state-counts/object.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `object` | 77 | 0 | 0 | 1 | 0 | 78 | +| `object` | 78 | 0 | 0 | 1 | 0 | 79 | diff --git a/packages/spec/src/data/field-group-requires-feature.test.ts b/packages/spec/src/data/field-group-requires-feature.test.ts new file mode 100644 index 00000000000..5312fb209bd --- /dev/null +++ b/packages/spec/src/data/field-group-requires-feature.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { ObjectFieldGroupSchema, ObjectSchema } from './object.zod'; +import { featureGatePredicate, lowerRequiresFeature } from '../kernel/public-auth-features'; + +/** + * `fieldGroups[].requiresFeature` — the field-group twin of the action-level + * `requiresFeature`. It names a flag of `PUBLIC_AUTH_FEATURES`, is lowered at + * parse time into the group's `visibleWhen` by the same helper the action key + * uses, and is stripped from the output. These pins hold the four facts a + * consumer relies on: the lowering, the strip, the refusal of an unknown flag, + * and the composition with an authored `visibleWhen`. + */ +describe('ObjectFieldGroupSchema.requiresFeature', () => { + const gate = featureGatePredicate('multiOrgEnabled'); + + it('lowers into visibleWhen and strips the key', () => { + const out = ObjectFieldGroupSchema.parse({ + key: 'group_structure', + label: 'Group Structure', + requiresFeature: 'multiOrgEnabled', + }); + expect(out.visibleWhen).toEqual({ dialect: 'cel', source: 'features.multiOrgEnabled != false' }); + expect(out.visibleWhen).toEqual({ dialect: 'cel', source: gate }); + expect('requiresFeature' in out).toBe(false); + }); + + it('lowers an opt-in flag to its own gate, the one an action gets', () => { + const out = ObjectFieldGroupSchema.parse({ key: 'mfa', label: 'MFA', requiresFeature: 'twoFactor' }); + expect(out.visibleWhen).toEqual({ dialect: 'cel', source: 'features.twoFactor == true' }); + }); + + it('refuses a flag outside the public feature registry, at requiresFeature', () => { + const res = ObjectFieldGroupSchema.safeParse({ key: 'g', label: 'G', requiresFeature: 'multiOrg' }); + expect(res.success).toBe(false); + const issue = res.error!.issues[0]!; + expect(issue.code).toBe('invalid_value'); + expect(issue.path).toEqual(['requiresFeature']); + }); + + it('AND-composes with an authored visibleWhen: the author first, the gate last', () => { + const fromString = ObjectFieldGroupSchema.parse({ + key: 'g', + label: 'G', + visibleWhen: "record.kind == 'child'", + requiresFeature: 'multiOrgEnabled', + }); + expect(fromString.visibleWhen).toEqual({ + dialect: 'cel', + source: `(record.kind == 'child') && ${gate}`, + }); + + const fromEnvelope = ObjectFieldGroupSchema.parse({ + key: 'g', + label: 'G', + visibleWhen: { dialect: 'cel', source: "record.kind == 'child'" }, + requiresFeature: 'multiOrgEnabled', + }); + expect(fromEnvelope.visibleWhen).toEqual(fromString.visibleWhen); + }); + + it('leaves a group without the key exactly as before', () => { + const out = ObjectFieldGroupSchema.parse({ key: 'g', label: 'G', visibleWhen: 'record.x == 1' }); + expect(out.visibleWhen).toEqual({ dialect: 'cel', source: 'record.x == 1' }); + expect('requiresFeature' in out).toBe(false); + expect(ObjectFieldGroupSchema.parse({ key: 'g', label: 'G' }).visibleWhen).toBeUndefined(); + }); + + it('lowers inside an object, where authors write it', () => { + const obj = ObjectSchema.create({ + name: 'acme_unit', + label: 'Unit', + fields: { + title: { type: 'text', label: 'Title', group: 'basics' }, + parent_note: { type: 'text', label: 'Parent note', group: 'tree' }, + }, + fieldGroups: [ + { key: 'basics', label: 'Basics' }, + { key: 'tree', label: 'Tree', requiresFeature: 'multiOrgEnabled' }, + ], + }); + const [basics, tree] = obj.fieldGroups!; + expect(basics!.visibleWhen).toBeUndefined(); + expect(tree!.visibleWhen).toEqual({ dialect: 'cel', source: gate }); + expect('requiresFeature' in tree!).toBe(false); + }); +}); + +describe('lowerRequiresFeature: the slot is keyed on the shape', () => { + const ctx = { addIssue: () => undefined } as unknown as z.core.$RefinementCtx; + type ActionLike = { name: string; requiresFeature?: 'multiOrgEnabled'; visible?: boolean }; + type GroupLike = { key: string; requiresFeature?: 'multiOrgEnabled'; visibleWhen?: { dialect: 'cel'; source: string } }; + const action: ActionLike = { name: 'a', requiresFeature: 'multiOrgEnabled' }; + const group: GroupLike = { key: 'g', requiresFeature: 'multiOrgEnabled' }; + const gate = featureGatePredicate('multiOrgEnabled'); + + it('lowers each shape into its own slot, and a slot the shape does not read fails to compile', () => { + expect(lowerRequiresFeature(action, ctx)).toEqual({ name: 'a', visible: { dialect: 'cel', source: gate } }); + expect(lowerRequiresFeature(group, ctx, 'visibleWhen')).toEqual({ key: 'g', visibleWhen: { dialect: 'cel', source: gate } }); + // The two calls below are compile-time pins (check:test-typecheck holds + // them): each would write the gate into a key its schema never reads. + // @ts-expect-error an action's predicate slot is `visible`, not `visibleWhen` + lowerRequiresFeature(action, ctx, 'visibleWhen'); + // @ts-expect-error a field group's predicate slot is `visibleWhen`; the default slot is `visible` + lowerRequiresFeature(group, ctx); + }); +}); diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 50d11c75bb3..59ce62da1e7 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -117,6 +117,7 @@ export const objectForm = defineForm({ { label: 'Collapsed — collapsible, starts closed', value: 'collapsed' }, ] }, { field: 'visibleWhen', label: 'Visible When', type: 'code', language: 'expression', helpText: 'CEL predicate over the record (e.g. record.type == \'invoice\') — the entry form shows the whole group, header included, only while it is TRUE.' }, + { field: 'requiresFeature', label: 'Requires Feature', helpText: 'Deployment feature the group needs (e.g. multiOrgEnabled): where it is off, the entry form and the record detail page hide the whole group. Combined with Visible When, the group shows only while both hold. The fields stay readable through the API.' }, ], }, ], diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 7f70cb81172..708caee5e06 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -18,6 +18,7 @@ import { ObjectListViewSchema } from '../ui/view.zod'; */ import { EvaluatedExpressionInputSchema, TemplateExpressionInputSchema, type EvaluatedExpression, type EvaluatedExpressionInput } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; +import { PUBLIC_AUTH_FEATURE_NAMES, lowerRequiresFeature } from '../kernel/public-auth-features'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { closedObject, strictObject } from '../shared/strict-object'; import { ProtectionSchema } from '../shared/protection.zod'; @@ -1289,6 +1290,38 @@ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ "Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'`", ).meta({ title: 'Visible When' }), + /** + * Declarative capability gate — the field-group twin of the action-level + * `requiresFeature` (`ui/action.zod.ts`). Names a deployment feature flag of + * `PUBLIC_AUTH_FEATURES` (`@objectstack/spec/kernel`), the same vocabulary + * actions use, so a flag outside it is refused at parse. Lowered at parse + * time into this group's `visibleWhen` by `lowerRequiresFeature` — the same + * gate an action gets (`features.multiOrgEnabled != false` for a default-on + * flag, `== true` for an opt-in one) — and stripped from the output, so a + * consumer only ever sees the canonical `visibleWhen`. + * + * Composition with an authored `visibleWhen`: AND, the author's predicate + * first and the gate last — `() && ` — so the group + * shows only where both hold. A non-CEL `visibleWhen` refuses the pair; + * write the combined predicate by hand. + * + * Both renderers bind the host's `features` root when they evaluate a + * group's `visibleWhen` (the form's section gate and the record page's + * `record:details`), so the lowered gate is enforced on both. Like every + * field-group predicate it is presentation only: the fields stay readable + * through the API. + * + * Fault direction: this slot is fail-closed, an action's `visible` is + * fail-soft. A `features` scope that does not carry the flag (the console's + * `{}` until `/auth/config` resolves, or for good if that fetch fails) makes + * the gate fault rather than answer, so the group is HIDDEN there even for a + * default-on flag, where the same gate on an action would show it. The group + * appears once the host publishes the flag. + */ + requiresFeature: z.enum(PUBLIC_AUTH_FEATURE_NAMES).optional().describe( + "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.", + ).meta({ title: 'Requires Feature' }), + /** * [ADR-0085] Collapse behaviour of the group's rendered section, on every * surface (form, detail, drawer). One enum, three valid states — replaces @@ -1310,7 +1343,9 @@ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ collapsible: z.boolean().optional().describe("[DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum.").meta({ title: 'Collapsible' }), /** @deprecated [ADR-0085 → `collapse`] UI-dialect alias (pair with `collapsible`); mapped onto `collapse` at parse. */ collapsed: z.boolean().optional().describe("[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.").meta({ title: 'Collapsed' }), -})); + // `requiresFeature` → `visibleWhen`, the action precedent's lowering with the + // group's slot name; the key never reaches the output. +}).transform((group, ctx) => lowerRequiresFeature(group, ctx, 'visibleWhen'))); export type ObjectFieldGroup = z.input; /** Post-parse shape of {@link ObjectFieldGroup} — defaults applied, transforms run (ADR-0122). */ diff --git a/packages/spec/src/kernel/public-auth-features.test.ts b/packages/spec/src/kernel/public-auth-features.test.ts index 65e25ae1b6f..bc28b76f45a 100644 --- a/packages/spec/src/kernel/public-auth-features.test.ts +++ b/packages/spec/src/kernel/public-auth-features.test.ts @@ -57,8 +57,8 @@ describe('PUBLIC_AUTH_FEATURES registry', () => { expect(hasGates !== hasExempt, 'exactly one of gatedInputs / exempt must be set').toBe(true); }); - it('gatedInputs paths follow the .actions.[.params.] grammar', () => { - const PATH = /^[a-z][a-z0-9_]*\.actions\.[a-z][a-z0-9_]*(\.params\.[A-Za-z][A-Za-z0-9_]*)?$/; + it('gatedInputs paths follow the .actions.[.params.] | .fieldGroups. grammar', () => { + const PATH = /^[a-z][a-z0-9_]*\.(actions\.[a-z][a-z0-9_]*(\.params\.[A-Za-z][A-Za-z0-9_]*)?|fieldGroups\.[a-z_][a-z0-9_]*)$/; for (const [name, entry] of entries) { for (const path of entry.gatedInputs ?? []) { expect(path, `${name}: ${path}`).toMatch(PATH); diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index c8c0fbcf012..fbfc0adb741 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -30,6 +30,10 @@ import type { z } from 'zod'; * - `ui/action.zod.ts` — `requiresFeature` sugar on actions/params is lowered * at parse time (see {@link lowerRequiresFeature}) into the canonical * `visible` CEL predicate using {@link featureGatePredicate}. + * - `data/object.zod.ts` — the same sugar on an object's field group + * (`fieldGroups[].requiresFeature`) is lowered the same way into the + * group's `visibleWhen`, the section predicate the form and the record + * page both evaluate with the host's `features` root in scope. * - plugin-auth drift guard (key-set equivalence with `getPublicConfig()`). * - platform-objects completeness guard (`feature-gate-guard.test.ts`) — * every path in `gatedInputs` must resolve to an input whose `visible` @@ -63,6 +67,16 @@ export type PublicAuthFeatureSurface = 'crud' | 'login' | 'status'; * input (a mode that takes a capability away, not one that grants it); gate * with `features.X != true` so a missing/undefined flag keeps the input * visible, exactly as the flag being off does. + * + * "Keeps the input visible" is the fault direction of the slot the gate lands + * in, not of the gate (see {@link RequiresFeatureSlot}). In CEL, reading a key + * that `features` does not carry is a runtime fault ("No such key"), not + * `null`, so with `features = {}` every gate above faults instead of answering: + * - an action's or param's `visible` is fail-soft, so a faulting gate shows + * the input, which is the behavior the list above describes; + * - a field group's `visibleWhen` is fail-closed (a faulting section predicate + * hides its group), so the same gate HIDES the group until the host + * publishes the flag, whichever semantics it has. */ export type PublicAuthFeatureSemantics = 'opt-in' | 'default-on' | 'hide-when-on'; @@ -71,10 +85,12 @@ export type PublicAuthFeatureEntry = { semantics: PublicAuthFeatureSemantics; /** * Spec inputs gated on this flag. Path grammar: - * `.actions.` or `.actions..params.`. + * `.actions.`, `.actions..params.`, + * or `.fieldGroups.` for an object's field group (whose + * predicate slot is `visibleWhen`). * The platform-objects completeness guard resolves each path and asserts the - * target's `visible` predicate carries {@link featureGatePredicate} as a - * conjunct. A param path resolves to the FIRST param on that name or field; + * target's `visible` (a field group's `visibleWhen`) predicate carries + * {@link featureGatePredicate} as a conjunct. A param path resolves to the FIRST param on that name or field; * further params on the same field that reference the flag are booked by the * same path, whatever their gate (see `phoneNumber`). * Mutually exclusive with `exempt`. @@ -155,6 +171,9 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_organization.actions.delete_organization', 'sys_organization.actions.set_active_organization', 'sys_organization.actions.leave_organization', + // Parent Organization and Sort Order: they order and group organizations + // against each other, so Edit organization hides them on one organization. + 'sys_organization.fieldGroups.group_structure', ], notes: 'Reflects ACTUAL multi-tenancy capability (the tenancy posture enforces ' + @@ -380,26 +399,50 @@ const FEATURE_GATE_OPERATORS = { * The canonical CEL gate for a flag, per its default semantics: * `opt-in` → `features.X == true`; `default-on` → `features.X != false` * (so an absent flag — e.g. config not yet fetched — fails open); - * `hide-when-on` → `features.X != true` (absent fails open too). + * `hide-when-on` → `features.X != true` (absent fails open too). "Fails open" + * holds on a fail-soft slot only: on a fail-closed one (a field group's + * `visibleWhen`) the absent flag's fault hides — see + * {@link PublicAuthFeatureSemantics}. */ export function featureGatePredicate(name: PublicAuthFeatureName): string { return `features.${name} ${FEATURE_GATE_OPERATORS[PUBLIC_AUTH_FEATURES[name].semantics]}`; } -/** Object shape the lowering transform operates on (post field-level parse). */ -type WithRequiresFeature = { - requiresFeature?: PublicAuthFeatureName; - /** - * Already normalized by ExpressionInputSchema to the `{dialect, source}` - * envelope — except for the literal arm, which surfaces here verbatim on the - * surfaces that declare one (`ActionSchema.visible`, commit 97e7e3caa). - */ - visible?: boolean | ({ dialect?: unknown; source?: unknown } & Record); -}; +/** + * The predicate slots {@link lowerRequiresFeature} lowers into, each with its + * own fault direction (what a predicate that faults at evaluation does): + * - `visible` (an action or a param): fail-soft, a fault SHOWS the input; + * - `visibleWhen` (an object field group): fail-closed, a fault HIDES the + * group (both renderers' section gate). + */ +export type RequiresFeatureSlot = 'visible' | 'visibleWhen'; + +/** + * A slot's value after field-level parse: already normalized by + * ExpressionInputSchema to the `{dialect, source}` envelope, except for the + * literal arm, which surfaces here verbatim on the surfaces that declare one + * (`ActionSchema.visible`, commit 97e7e3caa). + */ +type SlotPredicate = boolean | ({ dialect?: unknown; source?: unknown } & Record); + +/** + * Object shape the lowering transform operates on (post field-level parse), + * keyed on the slot it lowers into: it may carry slot `S` and must not carry + * the other slot. So lowering an action (which has `visible`) into + * `'visibleWhen'`, or a field group (which has `visibleWhen`) into the default + * `'visible'`, fails to compile instead of writing the gate into a key the + * schema does not read. + */ +type WithRequiresFeature = + { requiresFeature?: PublicAuthFeatureName } + & { [K in S]?: SlotPredicate } + & { [K in Exclude]?: never }; /** * Lower the declarative `requiresFeature: ''` sugar into the canonical - * `visible` CEL predicate and strip the sugar key from the output — the same + * `visible` CEL predicate (or, on a field group, `visibleWhen` — the `slot` + * argument; every rule below reads the same with that slot's name) and strip + * the sugar key from the output — the same * schema-boundary fold onto one canonical key that ADR-0089 ran for * `visibleOn` / `visibility` → `visibleWhen` through 17: persisted artifacts, * lint, runtime, and objectui only ever see the canonical envelope. @@ -432,32 +475,45 @@ type WithRequiresFeature = { * Designed as a zod `.transform((v, ctx) => lowerRequiresFeature(v, ctx))` * appended after the schema's refinements. */ -export function lowerRequiresFeature( +export function lowerRequiresFeature< + T extends WithRequiresFeature, + S extends RequiresFeatureSlot = 'visible', +>( input: T, ctx: z.core.$RefinementCtx, + /** + * The predicate slot the gate lowers into. `visible` on actions and params; + * `visibleWhen` on an object's field group, whose section predicate slot is + * spelled that way (ADR-0089) — same gate, same composition, same refusals, + * each worded with the slot the author actually wrote. + */ + // `S` is `'visible'` exactly when the argument is omitted (its type default), + // so the value default matches it. + slot: S = 'visible' as S, ): Omit { const { requiresFeature, ...rest } = input; if (requiresFeature === undefined) return rest as Omit; const gate = featureGatePredicate(requiresFeature); - // Annotated rather than inferred: `rest` is a generic `Omit`, so - // `rest.visible` is a deferred indexed access that control flow cannot narrow - // — the `=== true` / `=== false` guards below would not strip the boolean arm - // off it, and the envelope spread at the end would not compile. - const existing: WithRequiresFeature['visible'] = rest.visible; + // Read through `T`'s slot-keyed half (a widening assignment, not a cast): + // `input[slot]` directly is a deferred `T[S]` that control flow cannot + // narrow, and resolving it through the whole constraint also meets the + // other-slot `never` half, since `S` is not known to be disjoint from it. + const slotted: { [K in S]?: SlotPredicate } = input; + const existing: SlotPredicate | undefined = slotted[slot]; // `true` is the explicit spelling of "no gate of my own" — same lowering as an // absent key. `false` can never be gated into visibility, so it is refused. if (existing === undefined || existing === true) { - return { ...rest, visible: { dialect: 'cel', source: gate } } as Omit; + return { ...rest, [slot]: { dialect: 'cel', source: gate } } as Omit; } if (existing === false) { ctx.addIssue({ code: 'custom', path: ['requiresFeature'], message: - '`requiresFeature` cannot compose with `visible: false` — the literal already hides this ' + + `\`requiresFeature\` cannot compose with \`${slot}: false\` — the literal already hides this ` + 'unconditionally, so the feature gate can never take effect. Drop `requiresFeature` to keep it ' + - 'hidden, or drop `visible: false` to let the flag decide.', + `hidden, or drop \`${slot}: false\` to let the flag decide.`, }); return rest as Omit; } @@ -466,7 +522,7 @@ export function lowerRequiresFeature( code: 'custom', path: ['requiresFeature'], message: - '`requiresFeature` composes only with a CEL `visible` carrying a `source` string; ' + + `\`requiresFeature\` composes only with a CEL \`${slot}\` carrying a \`source\` string; ` + 'this expression is AST-only or non-CEL — write the combined predicate by hand.', }); return rest as Omit; @@ -484,19 +540,19 @@ export function lowerRequiresFeature( // pre-existing strings flagged with the interpolation in). The concrete // gate is one `featureGatePredicate` call away for anyone who wants it. message: - '`requiresFeature` composes only with a CEL `visible` carrying a NON-BLANK `source`; this ' + `\`requiresFeature\` composes only with a CEL \`${slot}\` carrying a NON-BLANK \`source\`; this ` + '`source` is blank after trimming, so composing the feature gate onto it would parenthesise ' + 'nothing — the predicate would read `( ) && ` followed by the gate — which no CEL parse ' + 'accepts on any scope. The gate would fault at evaluation instead of gating: the element is ' + 'shown regardless of the flag where the consuming surface is fail-soft and hidden regardless ' + 'of it where it is fail-closed, so the flag decides nothing — the inert arrival ADR-0078 ' - + 'rejects. Drop the blank `visible` and `requiresFeature` emits the gate alone, or put the ' + + `rejects. Drop the blank \`${slot}\` and \`requiresFeature\` emits the gate alone, or put the ` + 'predicate the gate should compose with in `source`.', }); return rest as Omit; } return { ...rest, - visible: { ...existing, source: `(${existing.source}) && ${gate}` }, + [slot]: { ...existing, source: `(${existing.source}) && ${gate}` }, } as Omit; }