Repository navigation
Commit c7d1367
feat(spec): an action param can name the widget that draws it (permission-ref picker) (#30321)
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBYXdLWhK9kFDrFra5KK9ZbM)_
domain:spec#3 · os-support-ai · session_01WGufzhmTfrR3b13itPTmu3
Fixes #30259
Part of #30117 (this is slice 1, the spec half; #30260 adds the console
reader, and #30117 keeps the server switch of Create User's param)
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up owed once #30260 lands (the skills lane is
paused, and the key does nothing in the console until then, so teaching
it now would teach an inert key). Owed lines:
skills/objectui/rules/protocol.md:309, the inline-param widget-config
list (`options`, `multiple`, `accept`, …) gains `widget`;
skills/objectstack-ui/rules/actions.md:174-175 says a field-backed param
takes its "widget" from the object metadata, which the console does not
do today (its resolver copies no field `widget` onto a param, measured
on #30117), so that line is #30260's to make true or to correct.
Prior rulings read: permission-ref,actionparamschema,action param widget
→ 1 hits; ADR-0104 D2; thread: none; repo: objectstack-ai/objectstack
ADR-0087: not needed. A new optional key; nothing is removed, renamed or
narrowed, and no stored param changes meaning, so no conversion is owed.
## 维护者速读
- **改了什么:** 动作参数(`ActionParamSchema`)新增可选键 `widget`,与字段上的 `widget`
同名、同词汇、同含义:指定用哪个已注册的控件来画这个参数,`type` 仍是值的类型。Studio
的动作表单参数列表多一列「控件」,四种语言都有标签。
- **为什么改:** 新建用户时「权限集」是一个只认机器名的文本框(#30117)。授权表上的同一列早已用 `permission-ref`
选择器(ADR-0131 D4),但动作参数没有任何方式请求这个选择器。这是拆分后的第一片:先让规范能表达。
- **风险与回滚:** 低。纯新增可选键:不写它的参数解析结果与之前完全一样;未注册的控件名会被保留并退回 `type`
的默认控件,和字段一致。控制台在 #30260 落地前不读它,所以现在声明了也只是照旧显示文本框;键的说明写明了这一点,`os
validate` / `os lint` 也会提醒写了它的作者。回滚即还原本 PR。
- **先看:** `packages/spec/src/ui/action.zod.ts`(新键与
TSDoc);`packages/spec/src/ui/action-param-widget.test.ts`(与字段键逐值同判的钉);`packages/spec/liveness/action.json`(`params`
钻取后的逐成员判定,`widget` 为 planned 并提醒作者)。
- **两分钟验证:** `pnpm --filter @objectstack/spec exec vitest run
src/ui/action-param-widget.test.ts`,预期 `17 passed`。
- **需要你做的:** 无,席位落地。
## What changes
- **`@objectstack/spec`: `ActionParamSchema.widget`**,
`z.string().optional()`, placed after `type`. Same key, vocabulary and
meaning as `FieldSchema.widget` (`data/field.zod.ts`): it names a
registered field component, looked up as `field:` plus the name, that
draws the param instead of the `type` default. `type` stays the value's
type. The TSDoc cites ADR-0131 D4 and names the case: `widget:
'permission-ref'` on a `text` param lists the security catalog's
permission sets and submits the chosen machine name. The `.describe()`
carries the same contract for the generated docs and JSON Schema.
- **The action form (`ui/action.form.ts`)**: the `params` repeater
offers `widget` as a row property (label `Widget`), and
`@objectstack/platform-objects`' metadata-form bundles label it in en /
zh-CN / ja-JP / es-ES, reusing each locale's existing word for the field
form's `widget` row (`控件覆盖`, `ウィジェット指定`, `Control del campo`).
- **Liveness, `action/params` drilled**: `liveness/action.json`'s
`params` row now carries a `children` map, one verdict per member, and
`action/params` leaves the `containers` list of
`scripts/liveness/undrilled-containers.baseline.json` (the debt shrinks
by 19 child keys). 18 members are `live`, each with dated in-repo
evidence anchored on the consuming symbol: the console resolver
`resolveActionParam`, the dialog (`ActionParamDialog`,
`filterVisibleParams`, `CarryOverParam`, `serializeParamValues`),
`paramToField`, and the dispatcher's ADR-0104 D2 path
(`resolveDeclaredActionParams`, `validateActionParams`,
`summarizeActionParams`); `requiresFeature` cites
`lowerRequiresFeature`, its parse-time lowering, as the action-level row
does. `options` is itself a container, so it is drilled too (`label`,
`value`, `visibleWhen`, all `live`). `widget` is `planned` with
`authorWarn` and an `authorHint` saying the dialog does not read it yet
(the row's note names #30260 as the reader; author-facing text carries
no issue id, per `check:doc-authoring`), so `os validate` / `os lint`
warn an author who writes it (rule `liveness-planned-property`); its
note records what each reader does with it today.
`state-counts/action.md` moves 51/2/54 → 70/3/74
(live/planned/classified). Two measured notes ride on rows:
`objectOverride` is read by the console only (the dispatcher resolves a
field-backed param against the action's own object), and
`options.visibleWhen` is client-side only.
- **Describe**: the key's `.describe()` (and so the generated reference)
now says it is not read yet: until the console's dialog passes it
through, every param is drawn with its `type` renderer. A `//` comment
beside the key says the sentence comes off with #30260.
- **Generated**: `authorable-surface/ui.json` gains
`ui/ActionParam:widget`; `content/docs/references/ui/action.mdx` gains
the row (both regenerated, not hand-edited).
- **Count pins moved by the new form row** (platform-objects):
`action-body-panel-echo-decisions.test.ts` (params children 18 → 19) and
`object-lifecycle-panel-echo-decisions.test.ts` (positive control 701 →
702), each with a dated comment line.
- **Not changed**: `sys-user.object.ts`. Its `permissionSet` param
switches to `widget: 'permission-ref'` in #30117's server PR, once
#30260 gives the key a reader; declaring it now would ship a key the
console ignores. `BulkActionParamSchema` does not get the key: no bulk
action asks for a picker of this kind today.
### The choice: `widget` on the param, not `permission-ref` as a param
`type`
Measured on `origin/main` `959c26be`:
- **`permission-ref` is a widget hint, not a field type.** It is not in
`FieldType`. The grant column declares it as `Field.text({ widget:
'permission-ref' })`
(`plugin-security/src/objects/sys-user-permission-set.object.ts:104`),
the console registers it as `field:permission-ref` beside `object-ref`
and `position-ref`, and both the console's changelog and
`ui/packages/app-shell/src/utils/paramValueShape.ts:181` say it is
"widget-hint only — not a declarable param type". A param `type` reads
`FieldType`, so the `type` route means either adding `permission-ref` to
`FieldType` (which also makes it a declarable FIELD type, against
ADR-0131 D4's text-column-plus-widget shape) or forking a param-only
type vocabulary.
- **The `type` route would loosen the server's value check.** ADR-0104
D2 (the one prior ruling the check found) makes `type` the enforced
value contract: the dispatcher validates each param through
`valueSchemaFor(type)`. A `type` it does not know falls to the
explicitly-open branch (`field-value.zod.ts:797`, `z.unknown()`), so
`type: 'permission-ref'` would stop checking that the value is a string.
With `widget`, `type` stays `text` and D2's check is unchanged.
- **`widget` is one key with the field's own meaning**, so an author who
knows the field key already knows this one, and the console's resolver
can pass it through to the same `field:WIDGET` lookup the record form
uses.
**Value verdict: kept, as on the field.** `FieldSchema.widget` is an
open `z.string()`: an unregistered name parses and degrades to the
`type` renderer. The param key makes the same promise, and the pin
checks seven values (three registered names, one unregistered, a number,
a boolean, `null`) get the same verdict on both schemas.
### Who reads action params, and what each does with the key today
| Reader | What it does with `widget` before #30260 |
|:--|:--|
| Spec parse door: `ActionParamSchema` (strict) behind `PUT
/meta/action`, `MetadataManager.validate`, `defineStack`, `os validate`
| Before this PR: refused as an unrecognized key. After: kept in the
parsed param. |
| Console resolver `ui/packages/fields/src/resolveActionParams.ts` |
Builds `ActionParamDef` from named members only; `ActionParamDef` has no
`widget`, so the key is not carried. The param is still resolved and
drawn. |
| Console adapter `ui/packages/app-shell/src/utils/paramToField.ts` |
Sets a field `widget` only for `boolean` (`checkbox`); otherwise draws
the `type` renderer. A `text` param stays a text box and its value is
submitted as before. Nothing is dropped. |
| REST / MCP dispatch (`packages/runtime/src/action-execution.ts` →
`validateActionParams`) | Resolves each param to `{ name, type,
multiple, required, options }` (`ResolvedActionParam`) and validates the
value by `type` (ADR-0104 D2). `widget` is not read; the value check is
unchanged. |
| AI action tools (cloud `service-ai`
`action-tools.ts#buildParametersSchema`, the `params` row's evidence) |
NOT MEASURED: closed repo, not in this container. Per the ledger row it
builds one JSON Schema property per param from its declared shape; an
extra string key gives it nothing new to misread. |
| `os i18n extract` (`packages/cli/src/utils/i18n-extract.ts`) | Emits
`label` / `helpText` / `placeholder` / `options` leaves only; `widget`
is a machine name, not translatable copy. |
| `@object-ui/types` `ActionParam` | Derived from the spec schema's
`z.input` type, so it gains the member with no edit; its parity pins are
subset (direction) checks. Not run locally (ui is out of scope here);
CI's UI job runs them. |
## Tests
- New `packages/spec/src/ui/action-param-widget.test.ts` (17 cases):
accepts `permission-ref` on an inline `text` param and keeps it with
`type` unchanged; accepts it on a field-backed param; it is a declared
key (no `unrecognized_keys`); per-value parity with `FieldSchema.widget`
over seven values; an unregistered name is kept; a non-string is refused
on the `widget` path (`invalid_type`); five existing param shapes
(Create User's current inline `permissionSet` among them) parse with no
`widget` member added.
- New pin in `packages/lint/src/lint-liveness-properties.test.ts`: an
action whose params name `widget` draws exactly one
`liveness-planned-property` warning (through the shipped ledger and the
array fan-out); its live siblings stay silent; an action whose params
name no widget draws nothing.
- Suites, under the verify lock:
- `pnpm --filter @objectstack/spec test`: Test Files 813 passed (813),
Tests 25592 passed, 1 todo, on the drilled tree (4fe77e8's content).
`pnpm --filter @objectstack/spec typecheck`: exit 0 (first head).
- On `1d215c5c` (the describe and hint rewording):
`lint-liveness-properties.test.ts` 113/113;
`action-param-widget.test.ts` + `action.test.ts` 168/168.
- `pnpm --filter @objectstack/metadata-protocol test`: Test Files 312
passed, 4 skipped (316), Tests 22354 passed, 26 skipped (first head; the
later commits touch no metadata-protocol input).
- `pnpm --filter @objectstack/platform-objects test`: the first run had
2 failures, both count pins the new form row moves
(`action-body-panel-echo-decisions.test.ts` 18 → 19,
`object-lifecycle-panel-echo-decisions.test.ts` 701 → 702). Both moved
in the second commit; the two files then pass (57/57), and the other 108
files passed.
- Ablation of the key (first head, `ablation-replace.mjs`, see
*Verification*). The ledger drill is data, so it is not ablated; the
lint pin is its red-without-it check.
<details><summary>Derived gates on the final head 1d215c5: 107
derived, 107 run, all exit 0</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 107 families against merge base
959c26b; a runner recorded `CMD :: exit N` for each (with
`NODE_OPTIONS=--max-old-space-size=4096`, after a full `turbo run
build`), and `--ran` reconciled: "107 derived famil(ies) accounted for —
107 run, 0 NOT-MEASURED".
- `check:liveness`: "every governed-type property, at every depth the
ledger drills, is classified … state-counts/ is current".
- `check:dual-build-cjs-loads`: "116 published require entry point(s)
across 71 package(s) load; 2456 emitted CommonJS file(s) parse" (NOT
MEASURED on the first head; measured here after the full build).
- `check:doc-authoring`: "21066 customer-facing string(s) … clean — no
internal issue-id references". Its first run on `4fe77e88` refused the
describe's `objectstack#30260`; `1d215c5c` moved that pointer to a `//`
comment and out of the `authorHint`.
- `check:docs`: "240 generated files in sync with packages/spec".
- `check-changeset-no-major --base origin/main`: no `major` bump; the
declaration axis reads this body in CI.
</details>
## Acceptance notes
- **The liveness row, after review.** The first head (`792c7abe`) wrote
`widget`'s planned status into the note of the undrilled `params` row,
which no check scans and no lint reads. The contract review (6651175841)
asked for the drill instead, and this head does it: 22 rows (19 members
plus `options`' 3), each verdict from its own reader, none fanned out
from the parent. #30260 flips `params.widget` to `live` and drops its
`authorWarn` when the pass-through lands.
- **Found while drilling, not changed here**: the dispatcher's
`resolveDeclaredActionParams` does not read `objectOverride`, so a
cross-object field-backed param's server-side value check inherits from
the action's own object's field of that name (or is open when there is
none), while the console resolves it against the override object.
Recorded on the row's note; it is outside this card.
- The existing skills line that says a field-backed param takes its
widget from the object field
(skills/objectstack-ui/rules/actions.md:174-175) is false in the console
today; it is listed under *Skills-docs* above and in the card report,
with #30260 as its carrier.
## Verification
- `Metadata-protocol: added` (corrected from the malformed `additive`
the first head carried, which `Check Changeset` read as no declaration)
is read off this body by CI's `Check Changeset`; locally
`check-changeset-no-major` found no `major` bump. The changeset bumps
`@objectstack/spec` and `@objectstack/platform-objects` minor and
carries both declaration lines.
- Ablation, one run on the first head `792c7abe` through
`scripts/ablation-replace.mjs` (spec tests import the schema from `src`,
so no build sits between): renaming the key to `widgetAblated` (anchor 1
→ 0, blob `8cd40792` → `893ac68c`) turns `action-param-widget.test.ts`
red, 9 failed / 8 passed of 17 (the 8 are the five existing-param cases
and the three values both schemas refuse). Restored: blob equals HEAD
`8cd40792` and `git diff HEAD` is empty.
- Reverse check of the parity pin's premise: the field key's verdicts
are read from the built schema in the same test run, so if
`FieldSchema.widget` ever narrows to an enum the per-value cases go red
rather than silently diverging.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` is left to CI (the repo-wide run
is CI's).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the maintainer brief
is in Chinese by the repo's rule).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzhmTfrR3b13itPTmu3
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent fe835b4 commit c7d1367
16 files changed
Lines changed: 334 additions & 6 deletions
File tree
- .changeset
- content/docs/references/ui
- packages
- lint/src
- platform-objects/src/apps/translations
- spec
- authorable-surface
- liveness
- state-counts
- scripts/liveness
- src/ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
| 144 | + | |
144 | 145 | | |
145 | 146 | | |
146 | 147 | | |
| |||
234 | 235 | | |
235 | 236 | | |
236 | 237 | | |
| 238 | + | |
237 | 239 | | |
238 | 240 | | |
239 | 241 | | |
| |||
370 | 372 | | |
371 | 373 | | |
372 | 374 | | |
| 375 | + | |
373 | 376 | | |
374 | 377 | | |
375 | 378 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
187 | 187 | | |
188 | 188 | | |
189 | 189 | | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
190 | 218 | | |
191 | 219 | | |
192 | 220 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
637 | 637 | | |
638 | 638 | | |
639 | 639 | | |
640 | | - | |
641 | | - | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
642 | 643 | | |
643 | 644 | | |
644 | 645 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1756 | 1756 | | |
1757 | 1757 | | |
1758 | 1758 | | |
| 1759 | + | |
| 1760 | + | |
| 1761 | + | |
1759 | 1762 | | |
1760 | 1763 | | |
1761 | 1764 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1756 | 1756 | | |
1757 | 1757 | | |
1758 | 1758 | | |
| 1759 | + | |
| 1760 | + | |
| 1761 | + | |
1759 | 1762 | | |
1760 | 1763 | | |
1761 | 1764 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1756 | 1756 | | |
1757 | 1757 | | |
1758 | 1758 | | |
| 1759 | + | |
| 1760 | + | |
| 1761 | + | |
1759 | 1762 | | |
1760 | 1763 | | |
1761 | 1764 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1259 | 1259 | | |
1260 | 1260 | | |
1261 | 1261 | | |
1262 | | - | |
| 1262 | + | |
| 1263 | + | |
| 1264 | + | |
| 1265 | + | |
1263 | 1266 | | |
1264 | 1267 | | |
1265 | 1268 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1756 | 1756 | | |
1757 | 1757 | | |
1758 | 1758 | | |
| 1759 | + | |
| 1760 | + | |
| 1761 | + | |
1759 | 1762 | | |
1760 | 1763 | | |
1761 | 1764 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
158 | 158 | | |
159 | 159 | | |
160 | 160 | | |
| 161 | + | |
161 | 162 | | |
162 | 163 | | |
163 | 164 | | |
| |||
0 commit comments