From 6b159811becedafcd091d8748f78162d92355e3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:02:08 +0000 Subject: [PATCH 1/4] feat(spec): an action param can name the widget that draws it (permission-ref picker) ActionParamSchema gains an optional `widget`, the same key, vocabulary and meaning as FieldSchema.widget, so a param can ask for the permission-set picker ADR-0131 D4 gives the grant column. `type` stays the value's type; an unregistered name is kept and degrades to the type renderer. The action form's Params repeater offers the key, labelled in four locales. The console reader is #30260. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzhmTfrR3b13itPTmu3 --- .changeset/30259-action-param-widget.md | 13 +++ .../en.metadata-forms.generated.ts | 3 + .../es-ES.metadata-forms.generated.ts | 3 + .../ja-JP.metadata-forms.generated.ts | 3 + .../zh-CN.metadata-forms.generated.ts | 3 + packages/spec/authorable-surface/ui.json | 1 + packages/spec/liveness/action.json | 2 +- .../spec/src/ui/action-param-widget.test.ts | 97 +++++++++++++++++++ packages/spec/src/ui/action.form.ts | 1 + packages/spec/src/ui/action.zod.ts | 27 ++++++ 10 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 .changeset/30259-action-param-widget.md create mode 100644 packages/spec/src/ui/action-param-widget.test.ts diff --git a/.changeset/30259-action-param-widget.md b/.changeset/30259-action-param-widget.md new file mode 100644 index 00000000000..4baaf3849a2 --- /dev/null +++ b/.changeset/30259-action-param-widget.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': minor +--- + +feat(spec): an action param can name the widget that draws it (`widget`, same key as on a field), so a param can ask for the permission-set picker (ADR-0131 D4) + +Clause-②: yes (widening) +Metadata-protocol: additive + +- **`@objectstack/spec`: `ActionParamSchema.widget`**, an optional string with the same key, vocabulary and meaning as `FieldSchema.widget`. It names a registered field component, looked up as `field:` plus the name, to draw the param in the action dialog instead of the `type` default. `type` stays the value's type. The case it exists for is `widget: 'permission-ref'` on a `text` param: it lists the permission sets of the security catalog and submits the chosen machine name, the picker the grant column `sys_user_permission_set.permission_set` already uses. Like the field key it is an open string: an unregistered name is kept, and the dialog draws the `type` renderer instead. A non-string is refused. A param that does not declare the key parses exactly as before. Until now a param naming `widget` was refused as an unknown key. +- **Not read yet.** The console's param resolver does not carry the key to the dialog's widget until objectstack#30260 lands. Until then a param that declares it is drawn by its `type` renderer, and its value is submitted as before. +- **`@objectstack/platform-objects`: the action form's Params repeater offers the key** as a `Widget` column, labelled in en, zh-CN, ja-JP and es-ES. 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 d7993bd5eb7..28f59fbf475 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 @@ -1756,6 +1756,9 @@ export const enMetadataForms: NonNullable = { "params.type": { label: "Type" }, + "params.widget": { + label: "Widget" + }, "params.required": { label: "Required" }, 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 b2a44f09fe4..464ed702254 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 @@ -1756,6 +1756,9 @@ export const esESMetadataForms: NonNullable = "params.type": { label: "Tipo" }, + "params.widget": { + label: "Control del campo" + }, "params.required": { label: "Obligatorio" }, 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 dbe5b24c425..d7c71a5c6b1 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 @@ -1756,6 +1756,9 @@ export const jaJPMetadataForms: NonNullable = "params.type": { label: "型" }, + "params.widget": { + label: "ウィジェット指定" + }, "params.required": { label: "必須" }, 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 c856b2f2e04..e3b9f6fb75b 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 @@ -1756,6 +1756,9 @@ export const zhCNMetadataForms: NonNullable = "params.type": { label: "类型" }, + "params.widget": { + label: "控件覆盖" + }, "params.required": { label: "必填" }, diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 7d1c8d71f7c..f2ee3f4c054 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -158,6 +158,7 @@ "ui/ActionParam:requiresFeature", "ui/ActionParam:type", "ui/ActionParam:visible", + "ui/ActionParam:widget", "ui/ActionSession:organizationId", "ui/ActionSession:positions", "ui/ActionSession:roles", diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 4e6bdf703d7..2668d6ff7ef 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -95,7 +95,7 @@ "verifiedAt": "2026-09-15", "evidenceScope": "cross-repo", "evidence": "cloud @cb8ee7ff: packages/service-ai/src/tools/action-tools.ts#buildParametersSchema — each declared param becomes one JSON Schema property on the exposed tool.", - "note": "field/objectOverride/defaultFromRow/options/placeholder/helpText/defaultValue/required all consumed." + "note": "field/objectOverride/defaultFromRow/options/placeholder/helpText/defaultValue/required all consumed. `widget` (#30259, ADR-0131 D4) is the exception, and PLANNED: it is declared spec-first, and the console's param resolver (ui/packages/fields/src/resolveActionParams.ts, then ui/packages/app-shell/src/utils/paramToField.ts) does not carry it to the dialog's widget until #30260 adds that pass-through. Until then the dialog draws the param's `type` renderer and submits its value as before, which is the degradation the key's own describe() promises for an unregistered name. This container is undrilled (a `containers` row in packages/spec/scripts/liveness/undrilled-containers.baseline.json), so the gate asks no per-key verdict here and drilling it means evidence for every member; this sentence is the record until #30260 lands and flips it." }, "variant": { "status": "live", diff --git a/packages/spec/src/ui/action-param-widget.test.ts b/packages/spec/src/ui/action-param-widget.test.ts new file mode 100644 index 00000000000..e8edd62f66e --- /dev/null +++ b/packages/spec/src/ui/action-param-widget.test.ts @@ -0,0 +1,97 @@ +// #30259 — `ActionParamSchema.widget`: an action param can name the widget that +// draws it, with the same key, vocabulary and meaning as `FieldSchema.widget`. +// The case that asked for it is the permission-set picker ADR-0131 D4 gives the +// grant column `sys_user_permission_set.permission_set` (`widget: +// 'permission-ref'`): before this key no param could ask for that picker, so +// Create User's `permissionSet` param was a box for a typed machine name +// (#30117). +// +// These pins hold the widening: a param that declares the key parses and keeps +// it; an unregistered name is KEPT, exactly as the field key keeps it (both +// degrade to the `type` renderer at draw time); a non-string is refused; and a +// param that does not declare the key parses exactly as it did before. +import { describe, it, expect } from 'vitest'; +import { ActionParamSchema } from './action.zod'; +import { FieldSchema } from '../data/field.zod'; + +describe('ActionParamSchema.widget — a param names the widget that draws it (#30259)', () => { + it('accepts permission-ref on an inline text param and keeps it, with `type` unchanged', () => { + const r = ActionParamSchema.safeParse({ + name: 'permissionSet', + label: 'Permission Set', + type: 'text', + widget: 'permission-ref', + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + if (!r.success) return; + expect(r.data.widget).toBe('permission-ref'); + // `type` stays the VALUE's type: the widget changes how it is picked only. + expect(r.data.type).toBe('text'); + }); + + it('accepts the key on a field-backed param (an authored override, as every inline value is)', () => { + const r = ActionParamSchema.safeParse({ field: 'permission_set', widget: 'permission-ref' }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + if (!r.success) return; + expect(r.data.widget).toBe('permission-ref'); + }); + + it('is a declared key, not an unrecognized one', () => { + const r = ActionParamSchema.safeParse({ name: 'p', type: 'text', widget: 'permission-ref' }); + const issues = r.success ? [] : r.error.issues; + expect(issues.filter((i) => i.code === 'unrecognized_keys')).toEqual([]); + }); + + describe('value — an open string, matching FieldSchema.widget', () => { + // The field key is `z.string().optional()`: an unregistered name is kept and + // the renderer falls back to the `type` default. The param key makes the same + // promise, so each value below must get the same verdict on both surfaces. + const values: unknown[] = ['permission-ref', 'position-ref', 'object-ref', 'no-such-widget', 42, true, null]; + + for (const value of values) { + it(`gives ${JSON.stringify(value)} the verdict the field key gives it`, () => { + const onParam = ActionParamSchema.safeParse({ name: 'p', type: 'text', widget: value }); + const onField = FieldSchema.safeParse({ name: 'p', type: 'text', widget: value }); + expect(onParam.success).toBe(onField.success); + if (onParam.success && onField.success) { + expect(onParam.data.widget).toBe(value); + expect(onField.data.widget).toBe(value); + } + }); + } + + it('keeps an unregistered name rather than refusing it (it degrades at draw time)', () => { + const r = ActionParamSchema.safeParse({ name: 'p', type: 'text', widget: 'no-such-widget' }); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.widget).toBe('no-such-widget'); + }); + + it('refuses a non-string on the widget path', () => { + const r = ActionParamSchema.safeParse({ name: 'p', type: 'text', widget: 42 }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.some((i) => i.path.join('.') === 'widget' && i.code === 'invalid_type')).toBe(true); + }); + }); + + describe('existing params parse unchanged', () => { + const existing: Array> = [ + // Create User's param as it stands on main (sys-user.object.ts): inline text. + { name: 'permissionSet', label: 'Permission Set', type: 'text', required: false }, + { field: 'email' }, + { field: 'role', objectOverride: 'sys_member' }, + { name: 'inspector', label: 'Inspector', type: 'lookup', reference: 'sys_user' }, + { name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }] }, + ]; + + for (const param of existing) { + it(`parses ${JSON.stringify(param)} with no widget member added`, () => { + const r = ActionParamSchema.safeParse(param); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + if (!r.success) return; + expect('widget' in r.data).toBe(false); + }); + } + }); +}); diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 67836f47392..c86ce3de265 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -77,6 +77,7 @@ export const actionForm = defineForm({ { field: 'objectOverride', label: 'Object Override' }, { field: 'label', label: 'Label' }, { field: 'type', label: 'Type' }, + { field: 'widget', label: 'Widget' }, { field: 'required', label: 'Required' }, { field: 'options', label: 'Options' }, { field: 'placeholder', label: 'Placeholder' }, diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 197e8a8788e..8cd407926b4 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -236,6 +236,33 @@ export const ActionParamSchema = lazySchema(() => strictObject( label: I18nLabelSchema.optional().meta({ title: 'Label' }), /** Overrides the resolved field type (or sets it for inline params). */ type: FieldType.optional().meta({ title: 'Type' }), + /** + * Widget override (#30259) — the same key, with the same vocabulary and the + * same meaning, as `FieldSchema.widget`: it names a registered field + * component (looked up as `field:` plus this name) that draws this param in + * the dialog instead of the `type` default. `type` stays the VALUE's type; + * the widget only changes how the value is picked. + * + * The case it exists for is a name that must be picked, not typed: + * `widget: 'permission-ref'` on a `text` param lists the permission sets of + * the security catalog and submits the chosen machine name — the picker + * ADR-0131 D4 gives the grant column + * `sys_user_permission_set.permission_set` ("a picker lists the registry, an + * assignment names one of its entries"). Before this key no param could ask + * for it, so Create User's `permissionSet` was a box for a typed name. + * + * Like the field key it is an open string, not an enum: an unregistered name + * is kept and the dialog draws the `type` renderer instead, so it degrades + * safely. Planned: the console's param resolver does not carry it to the + * widget yet (#30260); until it does, the param draws its `type` renderer + * and its value is submitted as before. + */ + widget: z.string().optional().describe( + 'Widget override for this param — names a registered field component (resolved as `field:`) ' + + 'to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` ' + + 'stays the value\'s type. e.g. "permission-ref" on a text param picks a permission set by name ' + + '(ADR-0131 D4). Degrades to the `type` renderer when unregistered.', + ).meta({ title: 'Widget' }), /** * Required override; when omitted defaults to `false`. Consumers that wish * to inherit the underlying field's `required` flag should leave this From 792c7abe12b255d6f11d325b15083a7af76f1b05 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:15:13 +0000 Subject: [PATCH 2/4] chore(spec,platform-objects): regenerate the action reference and move the two row-label counts for params.widget Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzhmTfrR3b13itPTmu3 --- content/docs/references/ui/action.mdx | 3 +++ .../translations/action-body-panel-echo-decisions.test.ts | 5 +++-- .../object-lifecycle-panel-echo-decisions.test.ts | 5 ++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 9da9d6ecb15..1e6f4ebaaa1 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -141,6 +141,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | @@ -234,6 +235,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | optional | | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | @@ -370,6 +372,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | diff --git a/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts index 20a80a21f8b..c13467bb158 100644 --- a/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts @@ -637,8 +637,9 @@ describe('#19403 round 5 — the blind spot, FIFTH shape: one schema, two forms, // `repeater-row-properties.test.ts` derives a row property for every child // of a `type: 'repeater'` field. On `actionForm` that is exactly ONE // repeater — `params`, with eighteen children — and every one of those - // carried its own text in all three locales before this round. - expect(ACTION_ENUMERATED.covered.length).toBe(18); + // carried its own text in all three locales before this round. Nineteen + // since #30259 added `params.widget`, authored in all three locales too. + expect(ACTION_ENUMERATED.covered.length).toBe(19); expect(ACTION_ENUMERATED.covered).toContain('params.carryOver'); expect(ACTION_ENUMERATED.covered).toContain('params.requiresFeature'); for (const [locale, forms] of TRANSLATED_LOCALES) { 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 da392eb5285..688e2825ea7 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 @@ -1259,7 +1259,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 701 since #29442: the flow form's variables repeater offers // `objectName` (a start input declared a record id) — one row label, // authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(701); + // 702 since #30259: the action form's params repeater offers `widget` + // (the widget that draws the param, the same key as a field's) — one + // row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(702); } // ⭐ 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 From 4fe77e8880ed97fc7e39b22c651186ae620caa96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 06:27:06 +0000 Subject: [PATCH 3/4] fix(spec): drill action/params liveness, grade params.widget planned with an author warning, declare Metadata-protocol: added The contract review asked for the drill: each ActionParamSchema member gets its own verdict with in-repo evidence (options drilled too), action/params leaves the undrilled baseline, and params.widget is planned + authorWarn naming #30260. The key's describe says it is not read until #30260. The changeset's Metadata-protocol line takes the closed-set value added. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzhmTfrR3b13itPTmu3 --- .changeset/30259-action-param-widget.md | 4 +- content/docs/references/ui/action.mdx | 6 +- .../lint/src/lint-liveness-properties.test.ts | 28 ++++ packages/spec/liveness/action.json | 143 +++++++++++++++++- packages/spec/liveness/state-counts/action.md | 2 +- .../undrilled-containers.baseline.json | 1 - packages/spec/src/ui/action.zod.ts | 3 +- 7 files changed, 178 insertions(+), 9 deletions(-) diff --git a/.changeset/30259-action-param-widget.md b/.changeset/30259-action-param-widget.md index 4baaf3849a2..ab5fe63cc03 100644 --- a/.changeset/30259-action-param-widget.md +++ b/.changeset/30259-action-param-widget.md @@ -6,8 +6,8 @@ feat(spec): an action param can name the widget that draws it (`widget`, same key as on a field), so a param can ask for the permission-set picker (ADR-0131 D4) Clause-②: yes (widening) -Metadata-protocol: additive +Metadata-protocol: added - **`@objectstack/spec`: `ActionParamSchema.widget`**, an optional string with the same key, vocabulary and meaning as `FieldSchema.widget`. It names a registered field component, looked up as `field:` plus the name, to draw the param in the action dialog instead of the `type` default. `type` stays the value's type. The case it exists for is `widget: 'permission-ref'` on a `text` param: it lists the permission sets of the security catalog and submits the chosen machine name, the picker the grant column `sys_user_permission_set.permission_set` already uses. Like the field key it is an open string: an unregistered name is kept, and the dialog draws the `type` renderer instead. A non-string is refused. A param that does not declare the key parses exactly as before. Until now a param naming `widget` was refused as an unknown key. -- **Not read yet.** The console's param resolver does not carry the key to the dialog's widget until objectstack#30260 lands. Until then a param that declares it is drawn by its `type` renderer, and its value is submitted as before. +- **Not read yet.** The console's param resolver does not carry the key to the dialog's widget until objectstack#30260 lands. Until then a param that declares it is drawn by its `type` renderer, and its value is submitted as before. The key's description says so, and the liveness ledger grades it `planned` with an author warning: `os validate` and `os lint` warn an author who writes it. - **`@objectstack/platform-objects`: the action form's Params repeater offers the key** as a `Widget` column, labelled in en, zh-CN, ja-JP and es-ES. diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 1e6f4ebaaa1..e92588c4ce8 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -141,7 +141,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | | -| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until objectstack#30260 lands, the console's action dialog draws every param with its `type` renderer whatever this names. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | @@ -235,7 +235,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | optional | | -| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until objectstack#30260 lands, the console's action dialog draws every param with its `type` renderer whatever this names. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | @@ -372,7 +372,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | | -| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until objectstack#30260 lands, the console's action dialog draws every param with its `type` renderer whatever this names. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 42811070ee2..1650e2e3476 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -187,6 +187,34 @@ describe('lintLivenessProperties', () => { }).filter((f) => f.message.includes('objectName'))).toEqual([]); }); + // #30259 — `action.params[].widget` is declared before the console's dialog + // reads it (#30260 adds the pass-through), so the drilled row is `planned` + + // `authorWarn`. Pinned through the SHIPPED ledger and an array of params, so + // the drill (`params.widget`) and the array fan-out both have to hold. + it('warns on an action param that names a widget — planned, nothing reads it yet', () => { + const findings = lintLivenessProperties({ + actions: [{ + name: 'create_user', + params: [ + { name: 'email', type: 'email', required: true }, + { name: 'permissionSet', type: 'text', widget: 'permission-ref' }, + ], + }], + }); + const warned = findings.filter((f) => f.message.includes('sets `params.widget`')); + expect(warned).toHaveLength(1); + expect(warned[0].rule).toBe('liveness-planned-property'); + expect(warned[0].hint).toContain('nothing reads it yet'); + // The live siblings the drill graded stay silent. + for (const key of ['name', 'type', 'required', 'options', 'helpText', 'carryOver']) { + expect(ruleOf(findings, `params.${key}`), key).toBeUndefined(); + } + // And an action whose params name no widget draws nothing about it. + expect(lintLivenessProperties({ + actions: [{ name: 'a2', params: [{ name: 'note', type: 'text' }] }], + }).filter((f) => f.message.includes('widget'))).toEqual([]); + }); + it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => { // `experimental` warns implicitly — shouldWarn() treats a declared-but- // unenforced guarantee like an opted-in dead prop. Repointed from diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 2668d6ff7ef..b39530edb68 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -95,7 +95,148 @@ "verifiedAt": "2026-09-15", "evidenceScope": "cross-repo", "evidence": "cloud @cb8ee7ff: packages/service-ai/src/tools/action-tools.ts#buildParametersSchema — each declared param becomes one JSON Schema property on the exposed tool.", - "note": "field/objectOverride/defaultFromRow/options/placeholder/helpText/defaultValue/required all consumed. `widget` (#30259, ADR-0131 D4) is the exception, and PLANNED: it is declared spec-first, and the console's param resolver (ui/packages/fields/src/resolveActionParams.ts, then ui/packages/app-shell/src/utils/paramToField.ts) does not carry it to the dialog's widget until #30260 adds that pass-through. Until then the dialog draws the param's `type` renderer and submits its value as before, which is the degradation the key's own describe() promises for an unregistered name. This container is undrilled (a `containers` row in packages/spec/scripts/liveness/undrilled-containers.baseline.json), so the gate asks no per-key verdict here and drilling it means evidence for every member; this sentence is the record until #30260 lands and flips it." + "note": "field/objectOverride/defaultFromRow/options/placeholder/helpText/defaultValue/required all consumed. DRILLED 2026-11-05 (#30259): each member now carries its own verdict and in-repo evidence (the console resolver, the param dialog, paramToField, and the dispatcher's ADR-0104 D2 check), and the container left the undrilled-containers baseline. The container's own row keeps its cross-repo AI-tool evidence. `widget` is this type's planned member, with authorWarn, until #30260 gives it a reader.", + "children": { + "name": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (the request-body key of the resolved param, `param.name ?? ''` inline, falling back to the field name); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (keys the collected values and each control's error by `param.name`); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams (the dispatcher's ADR-0104 D2 descriptor is keyed by `p?.name ?? fieldRef`)" + }, + "field": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (a param with `field` takes the field-backed branch: label, type, options, placeholder, help, default and the picker group inherited from `owner.fields[param.field]`); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams (inherits type / multiple / required / options from the referenced field before validating the submitted value)" + }, + "objectOverride": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`const ownerName = param.objectOverride ?? ctx.objectName` picks the object whose field a field-backed param resolves against)", + "note": "Console only: the dispatcher's resolveDeclaredActionParams resolves a field-backed param against the action's own object and does not read this key, so its inherited value check is the own object's field (or open, when that object has no such field)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`resolveInlineI18nLabel(param.label, ctx.locale)`, else the field's label or the name); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (draws the control's label); packages/runtime/src/action-execution.ts#summarizeActionParams (the MCP action summary carries a string label)" + }, + "type": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.type ?? 'text'` inline, `param.type ?? field.type` field-backed); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (folds the type to the widget that draws it); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams and packages/spec/src/ui/action-params.zod.ts#validateActionParams (the submitted value is checked against `valueSchemaFor` of this type, ADR-0104 D2)" + }, + "widget": { + "status": "planned", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "authorWarn": true, + "authorHint": "Keep it, but nothing reads it yet: the console's action dialog draws the param with its `type` widget until objectstack#30260 passes `widget` through, so a param naming `permission-ref` is a text box for now.", + "note": "PLANNED RECORD (#30259, ADR-0131 D4): the widget override, the same key, vocabulary and meaning as FieldSchema.widget, declared spec-first so a param can ask for the `permission-ref` picker the grant column uses. Accepted by the parse door and carried in the parsed param; read by nothing yet, measured: the console's resolveActionParam builds an ActionParamDef from named members and copies no `widget`, paramToField sets a field widget only for `boolean`, and the dispatcher's resolveDeclaredActionParams copies five named members and validates by `type`, so the value contract is unchanged. The reader is #30260 (the resolveActionParams / paramToField pass-through); flip this row to live there. Create User's permissionSet param switches to it in #30117's server PR." + }, + "required": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.required ?? false`, field-backed `?? field.required`); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (a required param with no value blocks submit); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams and packages/spec/src/ui/action-params.zod.ts#validateActionParams (an absent required value is refused `required`)" + }, + "options": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (an authored list wins over the field's, its labels resolved by resolveOptionLabels); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (passes the list into the widget's field metadata); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams and packages/spec/src/ui/action-params.zod.ts#validateActionParams (a submitted value must be one of the option values, ADR-0104 D2)", + "children": { + "label": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveOptionLabels (resolves an inline per-locale map to the string the dialog shows, falling back to the value); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (re-localises each entry's label with pickLocalized before drawing it)" + }, + "value": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveOptionLabels (the fallback label of an unresolved entry); packages/spec/src/ui/action-params.zod.ts#validateActionParams (membership is judged over the option values through valueSchemaFor); ui/packages/core/src/evaluator/optionRules.ts#resolveCascadingOptions (filters the offered set by entry)" + }, + "visibleWhen": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveOptionLabels (an inline list reaches the dialog verbatim, `visibleWhen` included, the pinned fast path); ui/packages/core/src/evaluator/optionRules.ts#resolveCascadingOptions (the select, multiselect, radio and checkboxes widgets narrow the offered set by evaluating it)", + "note": "Client-side only: the dispatcher checks the submitted value against the option values and does not evaluate visibleWhen, so hiding an option is not authorization (the key's TSDoc says so)." + } + } + }, + "placeholder": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.placeholder`, field-backed `?? field.placeholder`); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (copied onto the widget's field metadata)" + }, + "helpText": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`actionCopySource(param.helpText)`, field-backed falling back to the field's help); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (fills its holes and draws it under the control); packages/runtime/src/action-execution.ts#summarizeActionParams (the MCP summary's help, falling back to the field description)" + }, + "defaultValue": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`rowDefault ?? param.defaultValue`); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (seeds the dialog state with it, so an untouched control submits it verbatim)" + }, + "multiple": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.multiple`, field-backed `?? field.multiple`); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (copied onto the widget's field metadata); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams and packages/spec/src/ui/action-params.zod.ts#validateActionParams (an array value shape when multi-valued)" + }, + "accept": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.accept`, field-backed `?? field.accept`); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (copied onto the upload widget's field metadata)" + }, + "maxSize": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.maxSize`, field-backed `?? field.maxSize`); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (copied onto the upload widget's field metadata)" + }, + "reference": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`referenceTo: param.reference` inline, `param.reference ?? field.reference` field-backed); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (a lookup param with no target degrades to a text input, paramDegradesWithoutTarget)" + }, + "defaultFromRow": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.defaultFromRow && ctx.row …` seeds the default from the current row's stored value)" + }, + "carryOver": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (copied onto ActionParamDef.carryOver on every branch); ui/packages/app-shell/src/views/ActionParamDialog.tsx#CarryOverParam (draws it as a read-only summary) and ui/packages/app-shell/src/views/ActionParamDialog.tsx#serializeParamValues (submits it verbatim, skipping the upload rewrite)" + }, + "visible": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (normaliseVisible); ui/packages/app-shell/src/views/ActionParamDialog.tsx#filterVisibleParams (evaluates the predicate and omits the param when it is false)" + }, + "requiresFeature": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "packages/spec/src/kernel/public-auth-features.ts#lowerRequiresFeature, wired as the param schema's .transform() in packages/spec/src/ui/action.zod.ts", + "note": "Author-facing sugar consumed AT PARSE TIME, as on the action itself: lowered into this param's `visible` predicate and stripped from the output, so no downstream reader sees it." + } + } }, "variant": { "status": "live", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 0fabbcf63fa..8580b0c7b86 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 51 | 0 | 0 | 1 | 2 | 54 | +| `action` | 70 | 0 | 0 | 1 | 3 | 74 | diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index cd85f162a71..11bafae87b8 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -58,7 +58,6 @@ ], "containers": [ "action/ai", - "action/params", "action/resultDialog", "agent/guardrails", "agent/memory", diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 8cd407926b4..d14782156d1 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -261,7 +261,8 @@ export const ActionParamSchema = lazySchema(() => strictObject( 'Widget override for this param — names a registered field component (resolved as `field:`) ' + 'to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` ' + 'stays the value\'s type. e.g. "permission-ref" on a text param picks a permission set by name ' - + '(ADR-0131 D4). Degrades to the `type` renderer when unregistered.', + + '(ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until objectstack#30260 ' + + 'lands, the console\'s action dialog draws every param with its `type` renderer whatever this names.', ).meta({ title: 'Widget' }), /** * Required override; when omitted defaults to `false`. Consumers that wish From 1d215c5caf8c191915f424e74dda7715c01af0fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 06:50:27 +0000 Subject: [PATCH 4/4] fix(spec): keep issue ids out of the widget describe and its author hint check:doc-authoring refuses an issue id in author-facing prose; the pointer to the reader card moves to a // comment beside the key. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzhmTfrR3b13itPTmu3 --- content/docs/references/ui/action.mdx | 6 +++--- packages/spec/liveness/action.json | 2 +- packages/spec/src/ui/action.zod.ts | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index e92588c4ce8..ec2eace6cae 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -141,7 +141,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | | -| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until objectstack#30260 lands, the console's action dialog draws every param with its `type` renderer whatever this names. | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console's action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | @@ -235,7 +235,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | optional | | -| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until objectstack#30260 lands, the console's action dialog draws every param with its `type` renderer whatever this names. | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console's action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | @@ -372,7 +372,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | | -| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until objectstack#30260 lands, the console's action dialog draws every param with its `type` renderer whatever this names. | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console's action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index b39530edb68..e5b4b9ef114 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -133,7 +133,7 @@ "verifiedAt": "2026-11-05", "evidenceScope": "in-repo", "authorWarn": true, - "authorHint": "Keep it, but nothing reads it yet: the console's action dialog draws the param with its `type` widget until objectstack#30260 passes `widget` through, so a param naming `permission-ref` is a text box for now.", + "authorHint": "Keep it, but nothing reads it yet: the console's action dialog draws the param with its `type` widget until the dialog passes `widget` through, so a param naming `permission-ref` is a text box for now.", "note": "PLANNED RECORD (#30259, ADR-0131 D4): the widget override, the same key, vocabulary and meaning as FieldSchema.widget, declared spec-first so a param can ask for the `permission-ref` picker the grant column uses. Accepted by the parse door and carried in the parsed param; read by nothing yet, measured: the console's resolveActionParam builds an ActionParamDef from named members and copies no `widget`, paramToField sets a field widget only for `boolean`, and the dispatcher's resolveDeclaredActionParams copies five named members and validates by `type`, so the value contract is unchanged. The reader is #30260 (the resolveActionParams / paramToField pass-through); flip this row to live there. Create User's permissionSet param switches to it in #30117's server PR." }, "required": { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index d14782156d1..b7423367238 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -257,12 +257,13 @@ export const ActionParamSchema = lazySchema(() => strictObject( * widget yet (#30260); until it does, the param draws its `type` renderer * and its value is submitted as before. */ + // The describe's "Not read yet" sentence comes off when #30260's pass-through lands. widget: z.string().optional().describe( 'Widget override for this param — names a registered field component (resolved as `field:`) ' + 'to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` ' + 'stays the value\'s type. e.g. "permission-ref" on a text param picks a permission set by name ' - + '(ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until objectstack#30260 ' - + 'lands, the console\'s action dialog draws every param with its `type` renderer whatever this names.', + + '(ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console\'s ' + + 'action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names.', ).meta({ title: 'Widget' }), /** * Required override; when omitted defaults to `false`. Consumers that wish