diff --git a/.changeset/action-param-option-visible-when.md b/.changeset/action-param-option-visible-when.md new file mode 100644 index 0000000000..f4c59c7416 --- /dev/null +++ b/.changeset/action-param-option-visible-when.md @@ -0,0 +1,49 @@ +--- +"@objectstack/spec": major +--- + +feat(spec): action param 的 `options[]` 讲得出逐选项 `visibleWhen` —— 一个接好线却被门挡着的门控能力 (#5016) + +`ActionParamSchema.options[]` 的契约一直是 `{ label, value }`,#4001 批 14 把它从「靠删除来执行」改成了说出口。批 14 同时记下一个它不打算猜的能力问题:这个选项列表该不该讲 `SelectOptionSchema`(`data/field.zod.ts`)已经声明的那套逐选项词汇?#5016 逐键量了一遍,答案**不是整套照搬,而是一个键**。 + +## 只开 `visibleWhen`,因为只有它有读者 + +| 键 | 声明在 | action param 选项这条路上的消费者 | 本次 | +|:--|:--|:--|:--| +| `visibleWhen` | `SelectOptionSchema` | **有** —— 四个选项控件全都经 `useCascadingOptions` → `resolveCascadingOptions` 按它过滤(ADR-0058 / objectui#2284) | **开放** | +| `color` | `SelectOptionSchema` | 无 —— 只有**已存值**的展示渲染器读(网格单元格 / 详情徽章);对话框只拿列表建输入控件,提交完就丢 | 继续拒绝,附指路 | +| `default` | `SelectOptionSchema` | 无,且是**层级写错** —— 对话框参数的默认值走参数自己的 `defaultValue`,高一层 | 继续拒绝,附改法 | +| `icon` / `disabled` | 仓里任何 spec 形状都没有 | 无 —— 只活在 objectui 内部 `SelectOptionMetadata` 接口里,四个选项控件里每一个 `disabled` 都是**字段级**的 `props.disabled` | 继续拒绝(#5016 的 C 选项未采纳) | + +挡在作者和一个**能工作**的逐选项门控之间的,此前就只有 spec 这道门:内联参数的 `options` 是逐字下沉的(objectui `resolveActionParam` 内联分支 `options: param.options` → `ActionParamDialog` 逐条 spread 只翻译 `label` → `paramToField` 原样交给控件),而 `ExpressionInputSchema` 产出的 `{ dialect, source }` 信封正是 `evalFieldPredicate` 接受的形状。 + +## 行为激活面 —— FROM → TO + +**这是本次最需要注意的一行:同一份元数据,以前写了等于没写,现在真的生效。** + +```diff + params: [{ + name: 'severity', type: 'select', + options: [ + { label: 'Normal', value: 'normal' }, + { label: 'Overload', value: 'overload', + visibleWhen: "record.status == 'open'" }, + ], + }] +``` + +| 版本 | 上面这份 metadata 的下场 | +|:--|:--| +| 16.x | parse **成功**,出来的是 `{"label":"Overload","value":"overload"}` —— `visibleWhen` 在任何渲染器看到它之前就被静默剥掉,选项**永远可选** | +| 17.0.0-rc(#4001 批 14 起) | parse **失败**,`unrecognized_keys` 明确报错 | +| 17.0.0(本次) | parse 成功,键**保留并生效** —— `record.status != 'open'` 时该选项**不再出现在下拉里** | + +所以从 16.x 升上来的应用,如果曾经推测性地写过逐选项 `visibleWhen`(当时无害,因为它被丢掉了),升级后选项集会**变窄**。请复查这些谓词是否是你今天真正想要的:不想要就删掉键,想要就确认表达式对 `record` / `current_user` 求值的结果符合预期。`color` / `icon` / `disabled` / `default` 在 16.x 同样被静默剥掉,本次**不会**突然生效 —— 它们改为在 publish 时响亮拒绝,并各自指向该词汇真正生效的地方。 + +⚠️ **客户端隐藏是 UX,不是授权。** `enforceActionParams` 按声明的选项**值**校验提交(ADR-0104 D2),它不求值逐选项 `visibleWhen`;因访问控制而屏蔽的选项必须由 action 自身的 body 或权限检查再拒一次,只把它从下拉里藏掉是可绕过的。 + +## 本次**没有**修的一件事(objectui 侧,已另行记录) + +**字段回退那条路仍然丢键**:`resolveActionParam` 走的是 `param.options ?? normaliseOptions(field.options, …)`,而 `normaliseOptions` 把每个**继承来的**条目重建成 `{ label, value }`。这条丢弃早于本次改动、也不受本次影响(作者显式写的 `options` 数组优先级更高,压根不经过它),修复归属 objectui。因此本次的拒绝文案仍然刻意**不**开「把参数改成 field-backed 去继承」这张药方 —— 那是一条不存在的路(账本 finding 18:错误里的文案是行为,自信而错的处方比没有更糟)。 + +`bulk-action.zod.ts` 的 `.passthrough()` 特例维持不动:#4909 那两条理由(逐字到达 grid、objectui `BulkActionParam` 有显式 `[key: string]: unknown` 兜底)在这条路上都不成立,而这里的目标词汇是封闭的 —— 目标词汇封闭,正是「声明」胜过「容忍」的场合。 diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 89934e7481..1bf936d45e 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -167,7 +167,7 @@ const result = ActionSchema.parse(data); | **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` | optional | | | **required** | `boolean` | optional | | -| **options** | `{ label: string; value: string }[]` | optional | | +| **options** | `{ label: string; value: string; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }[]` | optional | | | **placeholder** | `string` | optional | | | **helpText** | `string` | optional | | | **defaultValue** | `any` | optional | | diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index c1853f2d47..0158116d75 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -146,6 +146,22 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 'system/settings-manifest.zod.ts:visible', ], }, + { + id: 'cel-action-param-option-visible', + summary: "action param option-list per-option gating (params[].options[].visibleWhen, #5016)", + // Same key, same evaluator and same binding environment as the per-option + // `visibleWhen` on a FIELD's option list — which is why it is `cel`, + // `interpret` and `fail-soft-log` like `cel-field-rule` rather than + // fail-closed: `evalFieldPredicate` is called with `fallback: true`, so a + // broken predicate leaves the option OFFERED instead of silently deleting a + // choice the author never meant to remove. It is a SEPARATE row from + // `cel-ui` because the evaluator differs: `cel-ui`'s surfaces hide an + // element through the SchemaRenderer, this one narrows an option LIST + // inside the field widgets. + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', + enforcement: 'console (objectui) ActionParamDialog → paramToField → SelectField / MultiSelectField / RadioField / CheckboxesField → useCascadingOptions → resolveCascadingOptions (core/evaluator/optionRules.ts) → evalFieldPredicate → @objectstack/formula celEngine (interpret), evaluated per OPTION against the live param bag + current_user; a value no longer offered is dropped from the param. UI gating only — `enforceActionParams` (ADR-0104 D2) validates the submitted value against the declared option VALUES and does not evaluate this predicate, so access-control gating must also be enforced by the action body / permissions', + covers: ['ui/action.zod.ts:visibleWhen'], + }, { id: 'cel-bulk-action-visible', summary: "selection-bar bulk action per-record eligibility (bulkActionDefs[].visible, objectui#3067)", diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index 7faa15a425..51fb6460d1 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -1407,3 +1407,166 @@ describe('#3896 close-out — retired shortcut/bulkEnabled', () => { expect(message).toMatch(/#3896/); }); }); + +// --------------------------------------------------------------------------- +// #5016 — per-option `visibleWhen` on an action param's option list +// --------------------------------------------------------------------------- + +/** + * #4001 批 14 closed this option entry at `{ label, value }` and filed the + * capability question as #5016. #5016 answered it PER KEY, on measurement of + * what an action param's option list can actually reach in objectui: + * + * - `visibleWhen` has a reader on this exact path, so it is declared. + * - `color` / `default` / `icon` / `disabled` do not, so they stay rejected — + * with the guidance that says where each vocabulary IS real. + * + * Every assertion below goes through a REAL door — `getMetadataTypeSchema('action')` + * (what `MetadataManager.validate` / `GET /api/v1/meta` / the Studio form use) + * or `ObjectSchema.actions[]` — rather than through `ActionParamSchema` + * directly, because the defect #5016 records was not "the sub-schema strips it" + * but "the key never survives the door an author's metadata actually crosses". + */ +describe('#5016 — action param option vocabulary', () => { + const gatedAction = { + name: 'escalate', + label: 'Escalate', + type: 'script' as const, + target: 'escalate_handler', + params: [{ + name: 'severity', + label: 'Severity', + type: 'select' as const, + options: [ + { label: 'Normal', value: 'normal' }, + { label: 'Overload', value: 'overload', visibleWhen: "record.status == 'open'" }, + ], + }], + }; + + it('SURVIVES the metadata door — declared AND delivered, not declared-then-stripped', () => { + const schema = getMetadataTypeSchema('action'); + expect(schema, "the 'action' metadata type must resolve to a schema").toBeDefined(); + const result = schema!.safeParse(gatedAction); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + + // The load-bearing half. Batch 14 measured this same payload coming back as + // `{"label":"Overload","value":"overload"}` — parsed clean, key gone before + // any renderer saw it. Asserting only `success` would still pass in that + // world, which is exactly the ADR-0078 shape this change exists to end. + const options = (result.data as any).params[0].options; + expect(options[1]).toMatchObject({ + label: 'Overload', + value: 'overload', + // `ExpressionInputSchema` normalises the authored string into the wire + // envelope objectui's `evalFieldPredicate` accepts (`FieldRulePredicate = + // string | { dialect?, source }`). + visibleWhen: { dialect: 'cel', source: "record.status == 'open'" }, + }); + // An option that declares no predicate stays predicate-free — `visibleWhen` + // is optional, not defaulted to an always-true expression. + expect(options[0].visibleWhen).toBeUndefined(); + }); + + it('survives the other real door too — nested in `object.actions[]`', () => { + const result = ObjectSchema.safeParse({ + name: 'crm_case', + label: 'Case', + fields: { status: { label: 'Status', type: 'text' } }, + actions: [gatedAction], + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect((result.data as any).actions[0].params[0].options[1].visibleWhen) + .toEqual({ dialect: 'cel', source: "record.status == 'open'" }); + }); + + it('accepts the canonical `{ dialect, source }` envelope as authored', () => { + const result = getMetadataTypeSchema('action')!.safeParse({ + ...gatedAction, + params: [{ + name: 'severity', + type: 'select' as const, + options: [{ + label: 'Overload', + value: 'overload', + visibleWhen: { dialect: 'cel', source: "'admin' in current_user.positions" }, + }], + }], + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('does NOT open the keys whose readers this surface cannot reach', () => { + // `color` / `default` are declared one layer down on `SelectOptionSchema`; + // `icon` / `disabled` are declared nowhere in the spec. Neither group has a + // consumer an action param's option list reaches — the dialog builds an + // INPUT from the list and discards it — so both stay rejected. Opening them + // for vocabulary symmetry would be the parses-clean-changes-nothing key. + for (const key of ['color', 'default', 'icon', 'disabled']) { + const result = getMetadataTypeSchema('action')!.safeParse({ + ...gatedAction, + params: [{ + name: 'severity', + type: 'select' as const, + options: [{ label: 'Overload', value: 'overload', [key]: key === 'disabled' || key === 'default' ? true : 'x' }], + }], + }); + expect(result.success, `\`${key}\` must stay rejected on an action param option`).toBe(false); + } + }); + + it('keeps each rejection pointing at where that vocabulary IS real', () => { + const messageFor = (option: Record): string => { + const r = getMetadataTypeSchema('action')!.safeParse({ + ...gatedAction, + params: [{ name: 'severity', type: 'select' as const, options: [option] }], + }); + return JSON.stringify(r.error?.issues ?? []); + }; + + // `color`: real one layer down, on the STORED-value display path. The + // sentence must no longer defer to #5016 as an open question — it is + // decided — and must not promise the field-backed inheritance route, which + // `normaliseOptions` still drops (ledger finding 18). + const color = messageFor({ label: 'A', value: 'a', color: 'red' }); + expect(color).toContain('SelectOptionSchema'); + expect(color).not.toContain('do not rely on it today'); + + // `default`: a wrong-LAYER key, not a missing capability. The prescription + // is the param's own `defaultValue`, one level up. + expect(messageFor({ label: 'A', value: 'a', default: true })).toContain('defaultValue'); + + // `icon`: declared nowhere — claiming it lives on `SelectOptionSchema` + // would be the false-prescription class. + const icon = messageFor({ label: 'A', value: 'a', icon: 'x' }); + expect(icon).toContain('no option shape in the spec declares'); + expect(icon).not.toContain('is a per-option key of a FIELD'); + }); + + it('points the two rival spellings at the newly declared key', () => { + for (const alias of ['visible', 'showWhen']) { + const r = getMetadataTypeSchema('action')!.safeParse({ + ...gatedAction, + params: [{ + name: 'severity', + type: 'select' as const, + options: [{ label: 'A', value: 'a', [alias]: "record.status == 'open'" }], + }], + }); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues)).toContain(`\`${alias}\` → \`visibleWhen\``); + } + }); + + it('leaves the PARAM-level canonical spelling alone — `visibleWhen` there still means `visible`', () => { + // The two surfaces have opposite canonical spellings on purpose (a param + // gates itself with `visible`; an option gates itself with `visibleWhen`), + // so opening the option key must not blur the one level up. + const r = getMetadataTypeSchema('action')!.safeParse({ + ...gatedAction, + params: [{ name: 'severity', type: 'text' as const, visibleWhen: 'features.x == true' }], + }); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues)).toContain('`visibleWhen` → `visible`'); + }); +}); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 730fa68f72..95f3bd9d8e 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -109,43 +109,60 @@ const ACTION_PARAM_KEY_ALIASES: Readonly> = { * the canonical one. */ /** - * Guidance for the two per-option keys that ARE declared one layer down, on - * `SelectOptionSchema` (`data/field.zod.ts`) — `color` and `visibleWhen`. + * Guidance for `color` — declared one layer down on `SelectOptionSchema` + * (`data/field.zod.ts`), and still not a key of THIS shape. * - * Built per key rather than shared, because `guidance` emits one bullet per - * offending key and a shared string prints the same sentence N times (the - * 批 10 `join`/`joinGateway` lesson). + * `visibleWhen` shared this text until #5016. The two were separated on + * measurement rather than on symmetry: both are declared on a FIELD's option + * list, but only one of them has a consumer an ACTION PARAM's option list can + * reach. * - * Note what it deliberately does NOT say. The obvious advice — "make the param - * field-backed and inherit the field's options" — is FALSE for these two: - * objectui's `resolveActionParams` lowers an inherited list through - * `normaliseOptions`, which rebuilds every entry as `{ label, value }`. Both - * directions drop the key, so the sentence names where the vocabulary is real - * without promising a route that does not exist (ledger finding 18: prose in a - * rejection is behaviour, and a confidently wrong one is worse than none). + * - `visibleWhen` is now declared below, because the reader is on this path. + * An inline param's `options` are lowered VERBATIM (objectui + * `resolveActionParam`'s inline branch → `paramToField` → + * `getLazyFieldWidget`), and every option widget narrows the offered set + * through `useCascadingOptions` → `resolveCascadingOptions`, which reads + * exactly this key (ADR-0058 / objectui#2284). + * - `color` has no reader here. It is consumed only where a STORED value is + * displayed — the grid cell / detail badge (`SelectCellRenderer`) and the + * state-machine viewer. An action param's option list never reaches those: + * the dialog builds an input from it, submits the picked value, and drops + * the list. The select / multiselect / radio / checkboxes INPUT widgets read + * `label`, `value` and `visibleWhen`, and nothing else. + * + * So `color` here is not "not yet, pending #5016" — #5016 measured it and the + * answer is no. Declaring it would add exactly the key that parses clean and + * changes nothing (ADR-0078), and would delete the only sentence telling an + * author where the vocabulary IS real. */ -const actionParamOptionDeclaredOnFieldOnly = (key: 'color' | 'visibleWhen'): string => - `\`${key}\` is a per-option key of a FIELD's option list (\`SelectOptionSchema\` in ` - + `\`data/field.zod.ts\`), where the object form and grid do read it. An action param's ` - + `options are \`{ label, value }\` — and the action metadata door has always stripped ` - + `anything else before a renderer could see it, so writing \`${key}\` here has never had ` - + `an effect. Whether this surface should carry the full per-option vocabulary is ` - + `#5016; do not rely on it today.`; +const actionParamOptionColorGuidance = + '`color` is a per-option key of a FIELD\'s option list (`SelectOptionSchema` in ' + + '`data/field.zod.ts`), read where a STORED value is displayed — the grid cell and the ' + + 'detail badge. An action param\'s options are never rendered that way: the dialog builds ' + + 'an INPUT from them, submits the picked value and discards the list, so no renderer would ' + + 'read `color` here even if this shape declared it (#5016 measured this). Drop the key — to ' + + 'colour the value once it is stored, declare the option list on the FIELD.'; /** * Guidance for per-option keys that no spec shape declares at all. * * `icon` / `disabled` exist only in objectui's internal `SelectOptionMetadata` - * interface, which nothing populates from metadata — so unlike `color` / - * `visibleWhen` there is no "one layer down" to point at, and saying there was - * would be the false-prescription class this campaign has already shipped four - * times (ledger finding 18). + * interface, which nothing populates from metadata and no widget reads — so + * unlike `color` / `visibleWhen` there is no "one layer down" to point at, and + * saying there was would be the false-prescription class this campaign has + * already shipped four times (ledger finding 18). + * + * #5016 re-measured both before deciding whether to converge the spec on + * objectui's interface (its option C) and found the same thing the batch-14 + * pass did: `SelectOptionMetadata.icon` has no reader anywhere in objectui, and + * every `disabled` in the four option widgets is the FIELD-level `props.disabled`, + * never a per-option one. C was therefore not taken. */ const actionParamOptionUndeclaredAnywhere = (key: 'icon' | 'disabled'): string => `no option shape in the spec declares \`${key}\` — not this one, and not the field-level ` + `\`SelectOptionSchema\`. It exists only inside objectui's own ` - + `\`SelectOptionMetadata\` type, which no metadata path populates. An action param's ` - + `options are \`{ label, value }\`; drop the key.`; + + `\`SelectOptionMetadata\` type, which no metadata path populates and no widget reads. An ` + + `action param's options are \`{ label, value, visibleWhen }\`; drop the key.`; const actionParamUnknownKeyError = strictUnknownKeyError({ surface: 'this action param', @@ -209,14 +226,40 @@ export const ActionParamSchema = lazySchema(() => z.object({ * `visibleWhen`), not an index signature. A closed target vocabulary is * exactly the case where declaring beats tolerating. * - * So the answer legitimately differs from the sibling's. What that leaves is - * a real, separable question — *should* an action param's option list speak - * the full per-option vocabulary that a FIELD's `options` - * (`SelectOptionSchema`, `data/field.zod.ts`) already declares and the object - * form already honours? That is a capability addition, not a strictness - * change, and it is filed as #5016 rather than guessed at here. Until it is decided, - * the honest contract is the one this schema has always had — `{ label, - * value }` — now said out loud instead of enforced by deletion. + * So the answer legitimately differs from the sibling's. What that left was a + * real, separable question — *should* an action param's option list speak the + * per-option vocabulary that a FIELD's `options` (`SelectOptionSchema`, + * `data/field.zod.ts`) already declares? That was filed as #5016 rather than + * guessed at here, and #5016 answered it **per key, on measurement**: + * + * - **`visibleWhen` — opened.** The reader is on this exact path and it works + * today. An inline param's `options` are lowered VERBATIM (objectui + * `resolveActionParam`'s inline branch does `options: param.options`; + * `ActionParamDialog` re-spreads each entry to localise `label`; + * `paramToField` passes the array straight into the widget's field + * metadata), and `SelectField` / `MultiSelectField` / `RadioField` / + * `CheckboxesField` all narrow the offered set through + * `useCascadingOptions` → `resolveCascadingOptions`, which reads this key + * and accepts the `{ dialect, source }` envelope `ExpressionInputSchema` + * emits. The spec door was the ONLY thing between an author and a working + * per-option gate. + * - **`color` / `default` — not opened**, and `icon` / `disabled` not added to + * `SelectOptionSchema` either (#5016's option C). None has a reader an + * action param's option list can reach; each keeps a `guidance` entry + * saying where the vocabulary IS real. Declaring them would be the + * parses-clean-changes-nothing key ADR-0078 exists to keep out — and for + * `default` it would actively mislead, since a dialog param defaults + * through `defaultValue` one level up. + * + * **What this does NOT fix**, deliberately, because it is objectui's and not + * the spec's: a FIELD-BACKED param that inherits its list instead of declaring + * one still loses the key. `resolveActionParam` reaches + * `param.options ?? normaliseOptions(field.options, …)`, and `normaliseOptions` + * rebuilds every inherited entry as `{ label, value }`. That drop predates + * this change, is invisible to it (an authored `options` array wins over the + * inherited one), and is tracked in objectui — so the guidance below still + * refuses to prescribe "make it field-backed and inherit" (ledger finding 18: + * a confidently wrong prescription is worse than none). * * The aliases are anchored on `SelectOptionSchema`'s own curated table (the * same idea, one layer down) rather than on edit distance, and deliberately @@ -240,20 +283,51 @@ export const ActionParamSchema = lazySchema(() => z.object({ optionValue: 'value', optionLabel: 'label', displayName: 'label', + // #5016 declared `visibleWhen` here, so `SelectOptionSchema`'s two + // spellings for it now point at a key this shape accepts and can carry + // across under the same finding-12 rule as the five above. + visible: 'visibleWhen', + showWhen: 'visibleWhen', }, guidance: { - // The four per-option keys that ARE real one layer down. Each says where - // the vocabulary lives and — critically — does NOT promise that a - // field-backed param inherits them: `resolveActionParams`' - // `normaliseOptions` rebuilds each entry as `{ label, value }`, so that - // promise would be false in exactly the way ledger finding 18 warns about. - color: actionParamOptionDeclaredOnFieldOnly('color'), - visibleWhen: actionParamOptionDeclaredOnFieldOnly('visibleWhen'), + // The per-option keys that are real one layer down but have no reader on + // THIS path, plus the two that no spec shape declares at all. Each says + // where the vocabulary lives and — critically — does NOT promise that a + // field-backed param inherits it: `resolveActionParams`' `normaliseOptions` + // rebuilds each inherited entry as `{ label, value }`, so that promise + // would be false in exactly the way ledger finding 18 warns about. + // + // `visibleWhen` is deliberately absent: it is a declared key now, and + // `guidance` is consulted only from the `unrecognized_keys` path, so an + // entry for it would be dead prose (`shared/alias-integrity.test.ts`). + color: actionParamOptionColorGuidance, icon: actionParamOptionUndeclaredAnywhere('icon'), disabled: actionParamOptionUndeclaredAnywhere('disabled'), default: '`default` on an OPTION is the field-level picklist default (`SelectOptionSchema.default`). A dialog param defaults through `defaultValue` on the PARAM itself, one level up — write `defaultValue: \'\'` there.', }, - }, { label: I18nLabelSchema, value: z.string() })).optional(), + }, { + label: I18nLabelSchema, + value: z.string(), + /** + * Per-option visibility predicate (CEL) — the option is offered only when + * this evaluates TRUE. Omit = always available (#5016). + * + * Same key, same engine and same binding environment as + * `SelectOptionSchema.visibleWhen` one layer down, so one vocabulary covers + * both surfaces: it expresses dependent options (`record.country == 'cn'`) + * AND role/context gating (`'admin' in current_user.positions`). In a + * dialog `record` is the live param bag overlaid on the row, so a param can + * gate its options on a SIBLING param the user has already filled. + * + * ⚠️ Client-side hiding is UX, not authorization. `enforceActionParams` + * validates the submitted value against this param's option VALUES + * (ADR-0104 D2) — it does not evaluate per-option `visibleWhen` — so an + * option gated for access-control reasons must also be refused by the + * action's own body or a permission check. Hiding it in the dropdown is + * bypassable. + */ + visibleWhen: ExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Same env as the field-level per-option visibleWhen (record + current_user). e.g. P`record.tier == 'gold'`"), + })).optional(), /** Placeholder override. */ placeholder: z.string().optional(), /** Help/description override. */