Skip to content

Commit f5aaa0b

Browse files
feat(ui): an action param's widget draws the named widget, so the action dialog can pick a permission set (#30260) (#30543)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu9k2t4srZBGSUnXnWt5NrsY)_ Fixes #30260 Clause-②: yes (widening) — `@object-ui/core`'s exported `ActionParamDef` gains one optional member, `widget?: string`, and `@object-ui/fields`' `RawActionParam` gains the same optional member (measured on the built `ui/packages/core/dist/actions/ActionRunner.d.ts` and `ui/packages/fields/dist/resolveActionParams.d.ts`). No accepted key, value or answer is taken away. Metadata-protocol: semantic Skills-docs: follow-up owed — skills/objectui/rules/protocol.md, skills/objectstack-ui/rules/actions.md ## 维护者速读 - 改了什么:动作参数上写的 `widget` 现在真的生效。参数写 `widget: 'permission-ref'` 时,动作对话框画权限集选择器(列出标签与机器名,提交机器名);未注册的名字退回 `type` 控件。 - 为什么改:spec 半边(#30259)已声明这个键,但控制台没人读,Create User 的权限集只能手敲名字;本卡是读者半边,之后 #30117 的服务端切换才有意义。 - 风险与回滚:只影响写了 `widget` 的参数(或继承字段 widget 的字段型参数);未写的参数渲染逐字节不变。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/utils/paramToField.ts`(`authoredWidgetKey` 与降级判断)、`ui/packages/fields/src/resolveActionParams.ts`(`inheritedWidget`)、`packages/spec/liveness/action.json`(`widget` 行翻 live)。 - 两分钟验证:`pnpm --dir ui exec vitest run -t 30260 packages/app-shell/src/views/ActionParamDialog.test.tsx`,预期 3 条通过(选择器提交 `support_agent`、无 widget 的对照是文本框、未注册名退回 textarea)。 - 需要你做的:无,席位落地。 ## What changes - **`ActionParamDef.widget`** (`ui/packages/core/src/actions/ActionRunner.ts`, the type only): the resolved param carries the spec's `ActionParamSchema.widget`. - **`resolveActionParam`** (`ui/packages/fields/src/resolveActionParams.ts`): copies the param's `widget` on the inline branch and the unresolved-field branch. On the field-backed branch the param's own `widget` wins; otherwise it inherits the field's `FieldSchema.widget`, but only while the param also inherits the field's `type` (or restates it). A param that overrides `type` asks for a different value, so a widget chosen for the field's value is not carried onto it (`inheritedWidget`). - **`paramToField`** (`ui/packages/app-shell/src/utils/paramToField.ts`): a `widget` that names a registered field widget replaces the `type` widget key the dialog mounts through `getLazyFieldWidget`; a name nothing registers keeps the `type` widget. The test is the shared resolver's own answer (`resolveFormWidgetType`, read for its documented `text` fallback), the same reading the console's `FormPage` makes of a field's `widget`. `paramDegradesWithoutTarget` answers false when a registered widget is drawn, so a targetless lookup param with such a widget gets neither the text fallback nor the paste-an-id hints, and the predicate/adapter equivalence still holds. - **Spec seam** (comment 6652583019): `packages/spec/liveness/action.json`'s `params.widget` row is `live`, citing both readers, with `authorWarn` / `authorHint` dropped; `packages/spec/src/ui/action.zod.ts` loses the describe's "Not read yet" sentence, the `//` pointer comment and the JSDoc "Planned" sentence. Generated with them: `content/docs/references/ui/action.mdx` (`gen:schema && gen:docs`) and `packages/spec/liveness/state-counts/action.md` (`gen:liveness-counts`). - One root changeset (`minor` on `@object-ui/core`, `@object-ui/fields`, `@object-ui/app-shell`, `@objectstack/spec`). ## Why `ActionParamSchema.widget` was accepted and stored but read by nothing, so a param that asked for the `permission-ref` picker drew a text box. This is the console half of #30117's split; Create User's param switch stays with #30117 (`packages/platform-objects/**` untouched). ## Tests New pins, each a top-level `describe` in the existing harness file: - `resolveActionParams.test.ts` — inline copy, field-backed inheritance, restated type inherits, overridden type does not, param beats field, no-widget control, unresolved branch keeps it, the public `ActionParam` type accepts it (8). - `paramToField.test.ts` — registered widget drawn, no-widget control, unregistered falls back to the param's own type widget (`text` / `select` / `textarea`), an authored `text` honoured, a targetless lookup with a registered widget is drawn and does not degrade, with the unregistered control still degrading (5). - `ActionParamDialog.test.tsx` — mounted inside `SchemaRendererProvider` with a catalog: the picker lists `Support Agent (support_agent)`, submits `{ permissionSet: 'support_agent' }`, and the emitted value matches `expectedParamShape` (`string`); CONTROL without `widget` is an input that lists nothing; an unregistered name on a `textarea` param is the textarea (3). ## Acceptance notes - **Bulk dialog not covered.** The selection bar's bulk dialog maps resolved params through plugin-grid's `toBulkParam` / `bulkParamToField`, whose `BulkActionParam` shape has no `widget`, so a promoted bulk action's param still draws its `type` widget. Outside this card's file surface; recorded in the liveness note. carrier: seat. - **A private copy of the "names a registered widget" test.** `authoredWidgetKey` restates `FormPage`'s `namesRegisteredWidget` (one line over `resolveFormWidgetType`). A shared export would mean editing `@object-ui/fields`' barrel and `ui/apps/console`, both outside the surface. carrier: seat. - **Field-widget inheritance rule** (a field-backed param inherits the field's `widget` only while it inherits or restates the field's `type`, a restated type matched through the param-type fold; a param that overrides `type` does not) is this PR's reading of the spec's "widget mapping from object metadata"; the seat accepted it at ACCEPT, and the spec JSDoc says the same. - Contract-review should-fixes (review 6661565862) landed in ae7e6fa: paramDegradesWithoutTarget judges the widget the param draws, inheritedWidget matches a restated type through the param-type fold, and the spec JSDoc says inherits or restates; 57e88ed turns the lint pin of the planned params.widget warning into its inverse. Delta review PASS 6662404259. - Branch is the card thread's harness branch `claude/project-thread-ijgrij` (claim amendment 6659791806), not `claude/issue-30260-*`. - `paramValueShape.ts` needed no edit: `expectedParamShape` resolves through `paramToField`, so a `permission-ref` param already reads its `string` contract row. ## Verification Ablations (each with `scripts/ablation-replace.mjs`, mutation proven on disk, restored to the HEAD blob; the ui suites alias `@object-ui/*` to source, so no build leg): - `paramToField`: authored widget leg removed ⇒ 5 of the 16 new tests red (the picker render, registered widget drawn, `text` honoured, targetless lookup, public type); controls green. - `resolveActionParam` inline `widget: param.widget` ⇒ `widget: undefined` ⇒ 2 red. - field-backed `widget: inheritedWidget(param, field)` deleted ⇒ 3 red. Local gates on `4d88bf5a` (the head after merging `origin/main`): `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 105 families, all 105 run, each exit code captured before any pipe; `--ran` reconciliation: **105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN**. Plus the order's extras: `pnpm --dir ui run check:spec-symbols` exit 0 and `node scripts/check-sdui-manifest.mjs` exit 0 (in the derived set). `check:dual-build-cjs-loads` read built output after a full `turbo run build` (131 packages, exit 0). Type-check: `pnpm --filter @object-ui/core run type-check`, `@object-ui/fields`, `@object-ui/app-shell` (the last re-run after the merge), all exit 0. Tests after the merge: `resolveActionParams.test.ts`, `paramToField.test.ts`, `ActionParamDialog.test.tsx`, `paramValueShape.test.ts` exit 0. Dogfood battery: n/a — no `!` in the title and no `(narrowing)` arm. <details><summary>Gate run record (105 derived + 1 extra)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:api-surface :: exit 0 pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0 pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0 pnpm --filter @objectstack/spec run check:export-origins :: exit 0 pnpm --filter @objectstack/spec run check:exported-any :: exit 0 pnpm --filter @objectstack/spec run check:generated :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:skill-refs :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-checklist :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-prior-rulings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:spec-docblock-symbol-anchors :: exit 0 pnpm check:spec-parsed-alias :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm --dir ui run check:spec-symbols :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgXcNPmrQhpwYYn7fEq)_ <!-- signed: domain:ui#4 · os-sales · session_01L6bhgVfPeFZkN1FP93imKU --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d0904c commit f5aaa0b

12 files changed

Lines changed: 369 additions & 32 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/fields': minor
4+
'@object-ui/app-shell': minor
5+
'@objectstack/spec': minor
6+
---
7+
8+
feat(ui): an action param's `widget` draws the named widget in the action dialog — `widget: 'permission-ref'` on a `text` param is the permission-set picker
9+
10+
Clause-②: yes (widening) — `@object-ui/core`'s exported `ActionParamDef` gains one optional member, `widget?: string`; `@object-ui/fields`' `RawActionParam` gains the same optional member. No accepted key, value or answer is taken away.
11+
Metadata-protocol: semantic
12+
13+
`ActionParamSchema.widget` (declared by `@objectstack/spec` with the same key, vocabulary and meaning as `FieldSchema.widget`) was accepted and stored but read by nothing: the action dialog drew every param with its `type` widget. The console now reads it:
14+
15+
- `resolveActionParams` copies the param's `widget` onto the resolved param. A field-backed param (`{ field }`) that inherits the field's `type` also inherits the field's `widget`; a param that overrides `type` does not, because the field's widget was chosen for the field's value. The param's own `widget` always wins.
16+
- `paramToField` draws the named widget when it is registered, in place of the `type` widget. A name nothing registers keeps the `type` widget (a `textarea` param stays a textarea), and a registered widget on a lookup param without a target is drawn instead of the plain record-id text box.
17+
- So `{ name: 'permissionSet', type: 'text', widget: 'permission-ref' }` opens the permission-set picker: the sets are listed by label beside their machine name, and the machine name is submitted. The server still validates the value by `type`, so the request body is unchanged.
18+
19+
The spec's liveness ledger marks `params.widget` live, and its `.describe()` no longer says the key is not read yet.

‎content/docs/references/ui/action.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
142142
| **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) |
143143
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
144144
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | |
145-
| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:<widget>`) 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. |
145+
| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:<widget>`) 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. |
146146
| **required** | `boolean` | optional (default: `false`) | |
147147
| **options** | `{ label: string \| Record<string, string>; value: string; visibleWhen?: string \| object }[]` | optional | |
148148
| **placeholder** | `string` | optional | |
@@ -236,7 +236,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
236236
| **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) |
237237
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
238238
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | optional | |
239-
| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:<widget>`) 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. |
239+
| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:<widget>`) 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. |
240240
| **required** | `boolean` | optional (default: `false`) | |
241241
| **options** | `{ label: string \| Record<string, string>; value: string; visibleWhen?: string \| object }[]` | optional | |
242242
| **placeholder** | `string` | optional | |
@@ -374,7 +374,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
374374
| **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) |
375375
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
376376
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | |
377-
| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:<widget>`) 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. |
377+
| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:<widget>`) 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. |
378378
| **required** | `boolean` | optional (default: `false`) | |
379379
| **options** | `{ label: string \| Record<string, string>; value: string; visibleWhen?: string \| object }[]` | optional | |
380380
| **placeholder** | `string` | optional | |

‎packages/lint/src/lint-liveness-properties.test.ts‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,12 @@ describe('lintLivenessProperties', () => {
187187
}).filter((f) => f.message.includes('objectName'))).toEqual([]);
188188
});
189189

190-
// #30259 — `action.params[].widget` is declared before the console's dialog
191-
// reads it (#30260 adds the pass-through), so the drilled row is `planned` +
192-
// `authorWarn`. Pinned through the SHIPPED ledger and an array of params, so
193-
// the drill (`params.widget`) and the array fan-out both have to hold.
194-
it('warns on an action param that names a widget — planned, nothing reads it yet', () => {
190+
// #30259 declared `action.params[].widget` before any reader (`planned` +
191+
// `authorWarn`, pinned here as a warning). #30260 gave it its console reader
192+
// and flipped the drilled row to `live`, so the SAME param now draws nothing.
193+
// Pinned through the SHIPPED ledger and an array of params, so the drill
194+
// (`params.widget`) and the array fan-out both have to hold.
195+
it('no longer warns on an action param that names a widget — live since its console reader (#30260)', () => {
195196
const findings = lintLivenessProperties({
196197
actions: [{
197198
name: 'create_user',
@@ -201,18 +202,23 @@ describe('lintLivenessProperties', () => {
201202
],
202203
}],
203204
});
204-
const warned = findings.filter((f) => f.message.includes('sets `params.widget`'));
205-
expect(warned).toHaveLength(1);
206-
expect(warned[0].rule).toBe('liveness-planned-property');
207-
expect(warned[0].hint).toContain('nothing reads it yet');
208-
// The live siblings the drill graded stay silent.
205+
expect(findings.filter((f) => f.message.includes('params.widget'))).toEqual([]);
206+
expect(ruleOf(findings, 'params.widget')).toBeUndefined();
207+
// The live siblings the drill graded stay silent too.
209208
for (const key of ['name', 'type', 'required', 'options', 'helpText', 'carryOver']) {
210209
expect(ruleOf(findings, `params.${key}`), key).toBeUndefined();
211210
}
212-
// And an action whose params name no widget draws nothing about it.
213-
expect(lintLivenessProperties({
214-
actions: [{ name: 'a2', params: [{ name: 'note', type: 'text' }] }],
215-
}).filter((f) => f.message.includes('widget'))).toEqual([]);
211+
// CONTROL: the same walk still warns on a drilled `planned` + authorWarn
212+
// row elsewhere in the shipped ledger, so the silence above is the
213+
// widget row's verdict, not a walk that reaches nothing.
214+
const flow = lintLivenessProperties({
215+
flows: [{
216+
name: 'close_account',
217+
variables: [{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }],
218+
nodes: [{ id: 'start', type: 'start' }],
219+
}],
220+
});
221+
expect(ruleOf(flow, 'variables.objectName')).toBe('liveness-planned-property');
216222
});
217223

218224
it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => {

‎packages/spec/liveness/action.json‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
"verifiedAt": "2026-09-15",
9696
"evidenceScope": "cross-repo",
9797
"evidence": "cloud @cb8ee7ff: packages/service-ai/src/tools/action-tools.ts#buildParametersSchema — each declared param becomes one JSON Schema property on the exposed tool.",
98-
"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.",
98+
"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` gained its console reader in #30260.",
9999
"children": {
100100
"name": {
101101
"status": "live",
@@ -129,12 +129,11 @@
129129
"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)"
130130
},
131131
"widget": {
132-
"status": "planned",
132+
"status": "live",
133133
"verifiedAt": "2026-11-05",
134134
"evidenceScope": "in-repo",
135-
"authorWarn": true,
136-
"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.",
137-
"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."
135+
"evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (copies the param's `widget` onto the resolved ActionParamDef inline; field-backed, the param's own else the field's FieldSchema.widget while the param inherits the field's `type`, via inheritedWidget); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (a `widget` naming a registered field widget replaces the `type` widget key the dialog mounts through getLazyFieldWidget; an unregistered name keeps the `type` widget, via authoredWidgetKey)",
136+
"note": "Declared spec-first by #30259 (ADR-0131 D4) so a param can ask for the `permission-ref` picker the grant column uses; the console reader landed in #30260, which flipped this row from planned and dropped its authorWarn. The value contract is unchanged: the dispatcher's resolveDeclaredActionParams still validates the submitted value by `type`, so a widget only changes how the value is picked. Create User's permissionSet param switches to it in #30117's server PR. Not read by the selection bar's bulk dialog (plugin-grid bulkParamToField), whose BulkActionParam shape carries no widget."
138137
},
139138
"required": {
140139
"status": "live",

‎packages/spec/liveness/state-counts/action.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `action` | 70 | 0 | 0 | 1 | 3 | 74 |
15+
| `action` | 71 | 0 | 0 | 1 | 2 | 74 |

‎packages/spec/src/ui/action.zod.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -253,17 +253,16 @@ export const ActionParamSchema = lazySchema(() => strictObject(
253253
*
254254
* Like the field key it is an open string, not an enum: an unregistered name
255255
* is kept and the dialog draws the `type` renderer instead, so it degrades
256-
* safely. Planned: the console's param resolver does not carry it to the
257-
* widget yet (#30260); until it does, the param draws its `type` renderer
258-
* and its value is submitted as before.
256+
* safely. The console reads it (#30260): `resolveActionParams` carries it
257+
* onto the resolved param — a field-backed param that inherits or restates
258+
* its `type` inherits the field's `widget` with it — and `paramToField` draws the named
259+
* widget. The server still validates the submitted value by `type`.
259260
*/
260-
// The describe's "Not read yet" sentence comes off when #30260's pass-through lands.
261261
widget: z.string().optional().describe(
262262
'Widget override for this param — names a registered field component (resolved as `field:<widget>`) '
263263
+ 'to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` '
264264
+ 'stays the value\'s type. e.g. "permission-ref" on a text param picks a permission set by name '
265-
+ '(ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console\'s '
266-
+ 'action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names.',
265+
+ '(ADR-0131 D4). Degrades to the `type` renderer when unregistered.',
267266
).meta({ title: 'Widget' }),
268267
/**
269268
* Required override; when omitted defaults to `false`. Consumers that wish

‎ui/packages/app-shell/src/utils/paramToField.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,63 @@ describe('paramDegradesWithoutTarget — one answer to "did this param degrade?"
380380
}
381381
});
382382
});
383+
384+
describe('paramToField — an authored `widget` draws the param (#30260)', () => {
385+
// `ActionParamDef.widget` is the spec's `ActionParamSchema.widget`, which
386+
// shares `FieldSchema.widget`'s meaning: a registered name wins over `type`,
387+
// an unregistered one degrades to the `type` widget.
388+
389+
it('draws the registered widget a text param names, in place of the text widget', () => {
390+
expect(paramToField(p({ type: 'text', widget: 'permission-ref' })).type).toBe('permission-ref');
391+
expect(paramToField(p({ type: 'text', widget: 'position-ref' })).type).toBe('position-ref');
392+
});
393+
394+
it('CONTROL: the same param without a widget is the text widget', () => {
395+
expect(paramToField(p({ type: 'text' })).type).toBe('text');
396+
});
397+
398+
it('falls back to the `type` widget when the name is not registered', () => {
399+
expect(paramToField(p({ type: 'text', widget: 'no_such_widget' })).type).toBe('text');
400+
// Not the text fallback: the param's OWN type widget.
401+
expect(paramToField(p({ type: 'select', widget: 'no_such_widget' })).type).toBe('select');
402+
expect(paramToField(p({ type: 'textarea', widget: 'no_such_widget' })).type).toBe('textarea');
403+
});
404+
405+
it('an authored `text` widget is honoured as the text widget, not read as "unregistered"', () => {
406+
expect(paramToField(p({ type: 'textarea', widget: 'text' })).type).toBe('text');
407+
});
408+
409+
it('a registered widget on a targetless picker param is drawn, and the param does not degrade', () => {
410+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
411+
try {
412+
const param = p({ type: 'lookup', widget: 'permission-ref' });
413+
expect(paramToField(param).type).toBe('permission-ref');
414+
expect(paramDegradesWithoutTarget(param)).toBe(false);
415+
expect(warn).not.toHaveBeenCalled();
416+
// CONTROL: an unregistered widget leaves the degradation exactly as before.
417+
const unregistered = p({ type: 'lookup', widget: 'no_such_widget' });
418+
expect(paramToField(unregistered).type).toBe('text');
419+
expect(paramDegradesWithoutTarget(unregistered)).toBe(true);
420+
} finally {
421+
warn.mockRestore();
422+
}
423+
});
424+
425+
it('a widget that names a picker with no target degrades like the picker type does, and warns', () => {
426+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
427+
try {
428+
const param = p({ type: 'text', widget: 'lookup' });
429+
expect(paramDegradesWithoutTarget(param)).toBe(true);
430+
expect(paramToField(param).type).toBe('text');
431+
expect(warn).toHaveBeenCalledTimes(1);
432+
// CONTROL: with a target the named picker is drawn, and nothing warns.
433+
warn.mockClear();
434+
const targeted = p({ type: 'text', widget: 'lookup', referenceTo: 'accounts' });
435+
expect(paramDegradesWithoutTarget(targeted)).toBe(false);
436+
expect(paramToField(targeted).type).toBe('lookup');
437+
expect(warn).not.toHaveBeenCalled();
438+
} finally {
439+
warn.mockRestore();
440+
}
441+
});
442+
});

0 commit comments

Comments
 (0)