diff --git a/.changeset/18177-bulk-action-param-strict.md b/.changeset/18177-bulk-action-param-strict.md new file mode 100644 index 00000000000..be93c659d9d --- /dev/null +++ b/.changeset/18177-bulk-action-param-strict.md @@ -0,0 +1,67 @@ +--- +'@objectstack/spec': minor +--- + +**BREAKING for authored metadata** — `BulkActionParamSchema` is strict, matching its single-record twin `ActionParamSchema`, and declares `dependsOn` (#18177, decision batch #146 item 4, letter A). + +Clause-②: yes (narrowing) + + + +A list view's `bulkActionDefs[].params[]` entry was `.passthrough()`, so **the shape examined nothing** — and that is the whole finding, not the framing. Measured against installed spec 17.4.0, three parses per schema in one process: + +| | positive control (minimal valid) | negative control (nonsense key) | subject (`dependsOn`) | +| --- | --- | --- | --- | +| `BulkActionParamSchema` | parses | **ACCEPTED** | accepted | +| `ActionParamSchema` | parses | refused `unrecognized_keys` | refused `unrecognized_keys` | + +It accepted `zzz_nonsense_key_that_no_producer_emits_8755` in the **same run** that it accepted `dependsOn`. ⇒ "the bulk schema accepts it" was never evidence that a key was licensed, in either direction: a shape that examines nothing can neither authorise `dependsOn` nor refuse a typo. Both control legs are now pinned in `src/ui/bulk-action.test.ts` in their post-close form, together, so a future re-opening of the shape cannot pass as a green `dependsOn` assertion. + +The maintainer's ruling: 「Breaking for authored metadata」, one-shot — no grace window, no dual spelling. + +### `dependsOn` is DECLARED, not refused — and needs no edit + +It was already live on this surface and the renderer honours it, so this half is a contract catching up with behaviour. `bulkParamToField` does not destructure it out, so it rides the adapter's spread onto the field metadata, where **both** widget families read it: the option family (`SelectField` / `MultiSelectField` / `RadioField` / `CheckboxesField`) gates and refreshes the offered set through `useCascadingOptions`, and the reference-bearing pickers (`LookupField`, and `UserField` through it) lower it into a hard candidate filter. Retiring it was measured off the table — an ablation removing it from that spread reddens 7 of 12 cases in the consuming repo. + +Shape and description mirror **`FieldSchema.dependsOn`**, which is the single-record twin *for this key*: `ActionParamSchema` declares no `dependsOn` at all, because the single-record dialog reaches it through the field-backed route this surface does not have. One vocabulary, two doors. + +```ts +params: [ + { name: 'account', type: 'lookup', object: 'showcase_account' }, + { name: 'contact', type: 'lookup', object: 'showcase_contact', dependsOn: ['account'] }, + { name: 'owner', type: 'lookup', object: 'sys_user', + dependsOn: [{ field: 'account', param: 'account_id' }] }, // remote key differs +] +``` + +On a bulk param the "record" a binding resolves against is the dialog's own in-progress param values — a bulk run holds a selection, not a row — so a binding names a **sibling param of the same def**. + +### Migration — FROM → TO + +Every rejection names the surface, echoes the key and carries its own fix. Nothing below is mechanical, which is why this registers as an ADR-0087 **D3 structured TODO** rather than a D2 conversion: an arbitrary unknown key has no mapping target, and deleting it automatically is the silent data loss ADR-0078 bans. + +| You wrote on a bulk param | Write instead | +| --- | --- | +| `helpText: '…'` | `help: '…'` | +| `defaultValue: x` | `default: x` | +| `reference: 'sys_user'` | `object: 'sys_user'` | +| `displayField: 'name'` | `labelField: 'name'` | +| `field: 'owner'` (field-backed param) | declare it inline — `name` + `type`, plus `object` for a picker. The bulk surface has no field-backed route: `resolveActionParams` consults the object's field definitions for the single-record dialog, `toBulkParam` never does | +| `visible: '…'` on the param | move the predicate to the DEF (`bulkActionDefs[].visible`), which gates the button and narrows the run per record | +| `visibleWhen: '…'` on the param | it is a per-**option** key — write it inside `options[]` | +| `carryOver` / `defaultFromRow` / `requiresFeature` / `objectOverride` | ACTION-param contracts with no bulk equivalent: a bulk dialog runs over a selection and holds no row. Use `default` for a fixed prefill, or the def's `patch` for a value the user must not see; gate the button with the def's `visible` / `requiredPermissions` | +| `min` / `max` / `step` / `precision` / `scale` / `rows` / `accept` / `maxSize`, or the picker knobs `lookupFilters` / `lookupColumns` / `lookupPageSize` / `descriptionField` / `picker` / `subtitle` / `avatarField` / `idField` / `allowCreate` | remove the key — see the warning below | + +### ⚠️ The widget-config family really was honoured, and really is refused now + +This is the half of the narrowing that costs something, so it is stated rather than buried. Those keys rode the same `...extra` spread `dependsOn` rides, and whichever widget read one honoured it (`min`/`max`/`step` at NumberField / SliderField / CurrencyField / PercentField, `accept`/`maxSize` at FileField / ImageField, `rows` at TextAreaField / RichTextField, the picker knobs at LookupField). They are refused now, with one prescription naming `FieldSchema` as the shape they are real on. + +⛔ **Do not read that prescription as "declare it on the object's field instead"** — the bulk surface has no field-backed param route, so the value does not reach this dialog either. If a bulk param genuinely needs one of these keys, it has to be declared on `BulkActionParamSchema`; open an issue rather than working around it. They were not declared here because the census below found no author writing one, and a declared key is published contract whose removal costs a full retirement. + +**Census, with its boundary.** Taken at authoring time over the two repositories reachable from that session: `objectstack@176b03582e` (7 authored bulk-param literals) and `objectui@3e4f6324f7` (3) — **zero** carrying a key this shape does not declare. ⚠️ **hotcrm was NOT REACHABLE and is UNMEASURED, not clean.** If you keep your own metadata corpus, run `objectstack validate` before upgrading rather than inheriting this result. + +### What is deliberately NOT closed + +`params[].options[]` stays `.passthrough()`, on its own measurement rather than by symmetry with its parent: `bulkParamToField` spreads every option entry into the field metadata, and the option widgets read `color` / `icon` / `disabled` / `visibleWhen` beyond the declared `{ label, value }`. Closing it would delete widget config the renderer honours — the exact defect this change closes one level up. The declared pair is still type-checked. + +⛔ No renderer is edited and no key is removed from any other shape. `BulkActionDefSchema` was already strict and is untouched. diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx index b57089eed28..3573a120c21 100644 --- a/content/docs/references/ui/bulk-action.mdx +++ b/content/docs/references/ui/bulk-action.mdx @@ -46,7 +46,7 @@ const result = BulkActionDefSchema.parse(data); | **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | -| **params** | `({ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … } & Record)[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | +| **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | | **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | @@ -69,6 +69,7 @@ const result = BulkActionDefSchema.parse(data); | **labelField** | `string` | optional | Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id). | | **multiple** | `boolean` | optional | Allow picking multiple values — the param value becomes an array and is written to the patch as-is. | | **placeholder** | `string` | optional | Placeholder text. | +| **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this param's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. On a BULK param the record is the dialog's own in-progress param values — a bulk run holds a selection, not a row — so a binding names a SIBLING PARAM of the same def. | --- @@ -111,6 +112,7 @@ const result = BulkActionDefSchema.parse(data); | **labelField** | `string` | optional | Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id). | | **multiple** | `boolean` | optional | Allow picking multiple values — the param value becomes an array and is written to the patch as-is. | | **placeholder** | `string` | optional | Placeholder text. | +| **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this param's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. On a BULK param the record is the dialog's own in-progress param values — a bulk run holds a selection, not a row — so a binding names a SIBLING PARAM of the same def. | ### Allowed Values: `BulkActionParam.type` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 295c01638df..df20ffef62d 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -1071,7 +1071,7 @@ View filter rule | **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | -| **params** | `({ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … } & Record)[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | +| **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | | **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | @@ -1473,7 +1473,7 @@ View filter rule | **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | -| **params** | `({ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … } & Record)[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | +| **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | | **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index fd9487920c0..f00b4554d0b 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 451 | +| Object sites in them | 452 | | Still-open (strip) sites | 126 | | Files carrying at least one | 22 | @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 177 | 166 | 4 | 0 | 7 | +| `ui/` | 178 | 168 | 3 | 0 | 7 | | `data/` | 159 | 76 | 1 | 0 | 82 | | `automation/` | 68 | 43 | 0 | 1 | 24 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **451** | **319** | **5** | **1** | **126** | +| **total** | **452** | **321** | **4** | **1** | **126** | ## File-level triage — site counts @@ -64,7 +64,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `action-params.zod.ts` | 1 | | `action.zod.ts` | 9 | | `app.zod.ts` | 18 | -| `bulk-action.zod.ts` | 3 | +| `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | | `component.zod.ts` | 48 | | `dashboard.zod.ts` | 11 | @@ -76,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 61 | | `widget.zod.ts` | 1 | -| **total** | **177** | +| **total** | **178** | ### `data/` — sites @@ -155,7 +155,7 @@ over it is here. ### `ui/` — open -**7 strip of 177**, in 4 file(s). +**7 strip of 178**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -163,7 +163,7 @@ over it is here. | `app.zod.ts` | 1 | 18 | | `view.zod.ts` | 4 | 61 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **177** | +| **total** | **7** | **178** | | Bucket | Sites | |---|---| diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 7110599aa0d..f870253db81 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -158,6 +158,7 @@ "ui/BulkActionDef:variant", "ui/BulkActionDef:visible", "ui/BulkActionParam:default", + "ui/BulkActionParam:dependsOn", "ui/BulkActionParam:help", "ui/BulkActionParam:label", "ui/BulkActionParam:labelField", diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-bulk-action-param-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-bulk-action-param-unknown-keys-refused.ts new file mode 100644 index 00000000000..b77b9c84470 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-bulk-action-param-unknown-keys-refused.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The one key this close DECLARES rather than refuses is `dependsOn`, so an author +// who wrote it keeps working and now has a contract saying so. Everything else +// undeclared becomes a parse error. Registered as a structured TODO (ADR-0087 D3) +// rather than a conversion (D2) for the reason the majors-15/16/17 strictness +// entries give: an arbitrary unknown key has no mapping target, and deleting it +// automatically is the silent data loss ADR-0078 bans. +export const entry: SemanticMigration = { + id: 'ui-bulk-action-param-unknown-keys-refused', + surface: + 'a list view\'s `bulkActionDefs[].params[]` entry (`BulkActionParamSchema`) — undeclared ' + + 'keys, which this shape accepted and forwarded while it was `.passthrough()`', + replacement: + 'the declared shape, now closed to match its single-record twin `ActionParamSchema`: ' + + '`{ name, type }` plus `label`, `help`, `required`, `default`, `options`, `object`, ' + + '`labelField`, `multiple`, `placeholder` and — new in this release — `dependsOn`. Every ' + + 'rejection names the surface, echoes the offending key and carries a rename or a ' + + 'prescription: the action-param spellings rename onto this surface\'s words (`helpText` → ' + + '`help`, `defaultValue` → `default`, `reference` → `object`, `displayField` → ' + + '`labelField`); the keys that belong one layer out are pointed there (`visible` and a ' + + 'capability gate belong on the DEF, `visibleWhen` belongs on an `options[]` entry, ' + + '`field` / `objectOverride` / `carryOver` / `defaultFromRow` / `requiresFeature` are ' + + 'field-backed ACTION-param contracts the bulk surface does not implement); and the ' + + 'widget-config family (`min`, `max`, `step`, `precision`, `scale`, `rows`, `accept`, ' + + '`maxSize`, and the picker knobs `lookupFilters` / `lookupColumns` / `lookupPageSize` / ' + + '`descriptionField` / `picker` / `subtitle` / `avatarField` / `idField` / `allowCreate`) ' + + 'is answered with one prescription naming `FieldSchema` as the shape those keys are real ' + + 'on. `dependsOn` needs NO edit — it is declared, in the same shape the field-level key ' + + 'takes (`[\'parent\']`, or `[{ field, param }]` when the remote filter key differs).', + reason: + 'The accept was a NULL READING, and that is what makes this a contract fix rather than a ' + + 'preference. Measured against installed spec 17.4.0, three parses per schema in one ' + + 'process: `BulkActionParamSchema` accepted `zzz_nonsense_key_that_no_producer_emits_8755` ' + + 'in the SAME RUN that it accepted `dependsOn`, while `ActionParamSchema` one surface over ' + + 'refused both with `unrecognized_keys`. A shape that examines nothing cannot license ' + + 'anything — so "the bulk schema accepts it" was never evidence a key was authorable, and ' + + 'every misspelling and every invented key shipped silently. Not losslessly convertible ' + + 'for the reason the majors-15/16/17 strictness entries give: an arbitrary unknown key has ' + + 'no mapping target and auto-deleting it would be the silent data loss ADR-0078 bans, so ' + + 'each occurrence needs an author\'s decision. ⚠️ Two halves of this are worth knowing ' + + 'before you upgrade. (1) `dependsOn` was already LIVE on this surface and is kept: ' + + '`bulkParamToField` does not destructure it out, so it rides the adapter\'s spread onto ' + + 'the field bag, where the option widgets read it through `useCascadingOptions` and the ' + + 'reference-bearing pickers lower it into a candidate filter; an ablation removing it from ' + + 'that spread reddened 7 of 12 cases in the consuming repo, so retiring it was measured off ' + + 'the table. (2) the widget-config family rode the same spread and really was honoured by ' + + 'whichever widget read it — those keys are refused now rather than forwarded, which is the ' + + 'accepted cost of closing the shape (maintainer ruling, decision batch #146 item 4, letter ' + + 'A, 2026-09-17: 「Breaking for authored metadata」, one-shot, no grace window and no dual ' + + 'spelling). ⛔ Do not read their rejection as "the renderer ignores them", and ⛔ do not ' + + 'answer it by declaring the key on the object\'s FIELD: the bulk surface has no ' + + 'field-backed param route, so that value does not reach this dialog either. A census of ' + + 'authored bulk params taken at registration time over the two repositories reachable from ' + + 'that session found ZERO carrying an undeclared key (objectstack@176b03582e: 7 param ' + + 'literals; objectui@3e4f6324f7: 3), so no in-corpus configuration is known to break. ' + + '⚠️ That census did NOT cover hotcrm, which was unreachable from the session that took it ' + + '— that leg is UNMEASURED, not clean, and an upgrader with their own metadata corpus ' + + 'should run the check below rather than inherit this result.', + acceptanceCriteria: + 'Every `bulkActionDefs[].params[]` entry in your stack parses with declared keys only — ' + + '`objectstack validate` (and `os lint` / `os build`) reports no `unrecognized_keys` under ' + + 'a `params` path. Each rejection carries its own fix; apply the rename it names, move the ' + + 'key to the layer the prescription points at, or delete metadata that was never read. ' + + '⚠️ Parsing clean is the weaker half here, because the widget-config keys were being ' + + 'HONOURED rather than dropped: for every param that carried one, re-open the bulk dialog ' + + 'and confirm the control still behaves as authored (a number param\'s bounds and step, a ' + + 'file param\'s accepted types and size cap, a picker\'s base filters and columns) — where ' + + 'it does not, the configuration is genuinely gone and the remedy is a spec issue asking ' + + 'for the key, not a local workaround. `dependsOn` needs no action: re-open one bulk dialog ' + + 'that declares it and confirm the dependent control is still gated until its parent param ' + + 'is filled, and that picking the parent still narrows the child.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 847f0075215..33e15aac98a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11977,6 +11977,77 @@ const step18: MigrationStep = { + 'and must be verified as such: nothing ever parsed or read these shapes, so removing ' + 'them removes no behaviour.', }, + // The one key this close DECLARES rather than refuses is `dependsOn`, so an author + // who wrote it keeps working and now has a contract saying so. Everything else + // undeclared becomes a parse error. Registered as a structured TODO (ADR-0087 D3) + // rather than a conversion (D2) for the reason the majors-15/16/17 strictness + // entries give: an arbitrary unknown key has no mapping target, and deleting it + // automatically is the silent data loss ADR-0078 bans. + { + id: 'ui-bulk-action-param-unknown-keys-refused', + surface: + 'a list view\'s `bulkActionDefs[].params[]` entry (`BulkActionParamSchema`) — undeclared ' + + 'keys, which this shape accepted and forwarded while it was `.passthrough()`', + replacement: + 'the declared shape, now closed to match its single-record twin `ActionParamSchema`: ' + + '`{ name, type }` plus `label`, `help`, `required`, `default`, `options`, `object`, ' + + '`labelField`, `multiple`, `placeholder` and — new in this release — `dependsOn`. Every ' + + 'rejection names the surface, echoes the offending key and carries a rename or a ' + + 'prescription: the action-param spellings rename onto this surface\'s words (`helpText` → ' + + '`help`, `defaultValue` → `default`, `reference` → `object`, `displayField` → ' + + '`labelField`); the keys that belong one layer out are pointed there (`visible` and a ' + + 'capability gate belong on the DEF, `visibleWhen` belongs on an `options[]` entry, ' + + '`field` / `objectOverride` / `carryOver` / `defaultFromRow` / `requiresFeature` are ' + + 'field-backed ACTION-param contracts the bulk surface does not implement); and the ' + + 'widget-config family (`min`, `max`, `step`, `precision`, `scale`, `rows`, `accept`, ' + + '`maxSize`, and the picker knobs `lookupFilters` / `lookupColumns` / `lookupPageSize` / ' + + '`descriptionField` / `picker` / `subtitle` / `avatarField` / `idField` / `allowCreate`) ' + + 'is answered with one prescription naming `FieldSchema` as the shape those keys are real ' + + 'on. `dependsOn` needs NO edit — it is declared, in the same shape the field-level key ' + + 'takes (`[\'parent\']`, or `[{ field, param }]` when the remote filter key differs).', + reason: + 'The accept was a NULL READING, and that is what makes this a contract fix rather than a ' + + 'preference. Measured against installed spec 17.4.0, three parses per schema in one ' + + 'process: `BulkActionParamSchema` accepted `zzz_nonsense_key_that_no_producer_emits_8755` ' + + 'in the SAME RUN that it accepted `dependsOn`, while `ActionParamSchema` one surface over ' + + 'refused both with `unrecognized_keys`. A shape that examines nothing cannot license ' + + 'anything — so "the bulk schema accepts it" was never evidence a key was authorable, and ' + + 'every misspelling and every invented key shipped silently. Not losslessly convertible ' + + 'for the reason the majors-15/16/17 strictness entries give: an arbitrary unknown key has ' + + 'no mapping target and auto-deleting it would be the silent data loss ADR-0078 bans, so ' + + 'each occurrence needs an author\'s decision. ⚠️ Two halves of this are worth knowing ' + + 'before you upgrade. (1) `dependsOn` was already LIVE on this surface and is kept: ' + + '`bulkParamToField` does not destructure it out, so it rides the adapter\'s spread onto ' + + 'the field bag, where the option widgets read it through `useCascadingOptions` and the ' + + 'reference-bearing pickers lower it into a candidate filter; an ablation removing it from ' + + 'that spread reddened 7 of 12 cases in the consuming repo, so retiring it was measured off ' + + 'the table. (2) the widget-config family rode the same spread and really was honoured by ' + + 'whichever widget read it — those keys are refused now rather than forwarded, which is the ' + + 'accepted cost of closing the shape (maintainer ruling, decision batch #146 item 4, letter ' + + 'A, 2026-09-17: 「Breaking for authored metadata」, one-shot, no grace window and no dual ' + + 'spelling). ⛔ Do not read their rejection as "the renderer ignores them", and ⛔ do not ' + + 'answer it by declaring the key on the object\'s FIELD: the bulk surface has no ' + + 'field-backed param route, so that value does not reach this dialog either. A census of ' + + 'authored bulk params taken at registration time over the two repositories reachable from ' + + 'that session found ZERO carrying an undeclared key (objectstack@176b03582e: 7 param ' + + 'literals; objectui@3e4f6324f7: 3), so no in-corpus configuration is known to break. ' + + '⚠️ That census did NOT cover hotcrm, which was unreachable from the session that took it ' + + '— that leg is UNMEASURED, not clean, and an upgrader with their own metadata corpus ' + + 'should run the check below rather than inherit this result.', + acceptanceCriteria: + 'Every `bulkActionDefs[].params[]` entry in your stack parses with declared keys only — ' + + '`objectstack validate` (and `os lint` / `os build`) reports no `unrecognized_keys` under ' + + 'a `params` path. Each rejection carries its own fix; apply the rename it names, move the ' + + 'key to the layer the prescription points at, or delete metadata that was never read. ' + + '⚠️ Parsing clean is the weaker half here, because the widget-config keys were being ' + + 'HONOURED rather than dropped: for every param that carried one, re-open the bulk dialog ' + + 'and confirm the control still behaves as authored (a number param\'s bounds and step, a ' + + 'file param\'s accepted types and size cap, a picker\'s base filters and columns) — where ' + + 'it does not, the configuration is genuinely gone and the remedy is a spec issue asking ' + + 'for the key, not a local workaround. `dependsOn` needs no action: re-open one bulk dialog ' + + 'that declares it and confirm the dependent control is still gated until its parent param ' + + 'is filled, and that picking the parent still narrows the child.', + }, { id: 'ui-cloud-connection-widgets-unknown-keys-refused', surface: 'page `cloud-connection:panel` / `marketplace:installed-list` components — ' diff --git a/packages/spec/src/ui/bulk-action.test.ts b/packages/spec/src/ui/bulk-action.test.ts index 6ad9cccd8cd..2b75694fab8 100644 --- a/packages/spec/src/ui/bulk-action.test.ts +++ b/packages/spec/src/ui/bulk-action.test.ts @@ -1,7 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { BulkActionDefSchema } from './bulk-action.zod'; +import { BulkActionDefSchema, BulkActionParamSchema } from './bulk-action.zod'; +import { FieldSchema } from '../data/field.zod'; +import { formatZodError } from '../shared/error-map.zod'; /** Parse and return the flattened issue messages (empty = clean). */ const reject = (input: unknown): string[] => { @@ -56,13 +58,26 @@ describe('BulkActionDefSchema (#4457)', () => { expect(def.params?.[0]).toMatchObject({ object: 'sys_user', labelField: 'name' }); }); - it('forwards unknown WIDGET config on a param — the renderer declares a catch-all', () => { - const def = ok({ + // ── RE-JUDGED, NOT RE-SPELLED (#18177) ───────────────────────────────── + // This case used to be `forwards unknown WIDGET config on a param — the + // renderer declares a catch-all`, and it asserted that `min`/`max`/`step` + // rode through. That is the exact behaviour letter A removed, so the + // fixture could not be repaired by changing a spelling: what it pinned is + // gone, and the honest replacement pins the REFUSAL plus the prescription + // that now has to carry an author across. The keys really are read by a + // widget once they reach the field bag — which is why the message says so + // instead of implying the renderer ignores them. + it('REFUSES unknown widget config on a param, and the rejection names where the vocabulary IS real', () => { + const issues = reject({ name: 'reschedule', operation: 'update', params: [{ name: 'shift_days', type: 'number', min: 1, max: 90, step: 1 }], - }); - expect(def.params?.[0]).toMatchObject({ min: 1, max: 90, step: 1 }); + }).join('\n'); + expect(issues).toContain('min'); + expect(issues).toContain('FieldSchema'); + // ⛔ The prescription must NOT send the author to the field-backed route: + // the bulk surface has none, so that answer would be confidently wrong. + expect(issues).toContain('no field-backed param route'); }); // ── DELIBERATE OPENNESS — DO NOT "FIX" THIS INTO A STRICT SITE ────────── @@ -287,4 +302,131 @@ describe('BulkActionDefSchema (#4457)', () => { .toContain('`requiredCapabilities` → `requiredPermissions`'); }); }); + + // ───────────────────────────────────────────────────────────────────────── + // #18177 — maintainer ruling batch #146 item 4, letter A. + // + // The card that produced this ruling turned on ONE measurement: the open + // shape accepted `zzz_nonsense_key_that_no_producer_emits_8755` in the same + // run that it accepted `dependsOn`, so its accept licensed neither. Both legs + // are pinned here in their POST-close form — the nonsense key is refused, and + // `dependsOn` parses because it is declared, not because nothing is checked. + // Keep the pair together: a `dependsOn` assertion alone would go green again + // the day someone re-opens the shape. + // ───────────────────────────────────────────────────────────────────────── + describe('— the shape is closed, so its accept means something (#18177)', () => { + const NONSENSE = 'zzz_nonsense_key_that_no_producer_emits_8755'; + + it('CONTROL (negative): the nonsense key the card measured is now refused by name', () => { + const r = BulkActionParamSchema.safeParse({ name: 'p', type: 'text', [NONSENSE]: 1 }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); + expect(JSON.stringify(r.error.issues)).toContain(NONSENSE); + }); + + it('CONTROL (positive): a minimal valid param still parses — the door refuses, it does not jam', () => { + expect(BulkActionParamSchema.safeParse({ name: 'p', type: 'text' }).success).toBe(true); + }); + + it('the twin refuses the same nonsense key — one strictness across both param surfaces', () => { + // `ActionParamSchema` has been strict since #3746; this is the asymmetry + // the card was filed about, now closed from the other side. + const r = BulkActionParamSchema.safeParse({ name: 'p', type: 'text', [NONSENSE]: 1 }); + expect(r.success).toBe(false); + }); + + it('`dependsOn` is DECLARED — both authored forms parse', () => { + const def = ok({ + name: 'reassign', + operation: 'update', + params: [ + { name: 'account', type: 'lookup', object: 'showcase_account' }, + { name: 'contact', type: 'lookup', object: 'showcase_contact', dependsOn: ['account'] }, + { name: 'owner', type: 'lookup', object: 'sys_user', dependsOn: [{ field: 'account', param: 'account_id' }] }, + ], + }); + expect(def.params?.[1]).toMatchObject({ dependsOn: ['account'] }); + expect(def.params?.[2]).toMatchObject({ dependsOn: [{ field: 'account', param: 'account_id' }] }); + }); + + // ⚠️ Read through `formatZodError`, the door an author really meets — NOT + // `issue.message`. A `dependsOn` entry sits behind a string-or-object + // union, whose top-level message is the bare `Invalid input`; the curated + // refusal lives one level down and is surfaced by `formatZodIssue`'s union + // descent. A raw-issue pin here would assert the union wrapper and report + // the rename as missing when it is rendered perfectly well (the exact + // misreading `shared/union-author-message-pins.test.ts` exists to stop). + it('the entry inside `dependsOn` is strict too — strictness does not recurse by itself', () => { + const r = BulkActionDefSchema.safeParse({ + name: 'reassign', + operation: 'update', + params: [{ name: 'owner', type: 'lookup', object: 'sys_user', dependsOn: [{ local: 'account' }] }], + }); + expect(r.success).toBe(false); + if (r.success) return; + const message = formatZodError(r.error as Parameters[0]); + expect(message).toContain('`local` → `field`'); + expect(message).toContain('this dependsOn entry'); + // The STRING arm's kind mismatch is not a prescription — it must not be + // what the author is shown. + expect(message).not.toContain('expected string, received object'); + }); + + // ⭐ The parity pin. The ruling said "the same shape and `describe` as the + // single-record twin", and the twin for THIS key is `FieldSchema.dependsOn` + // — `ActionParamSchema` declares no `dependsOn` at all, because the + // single-record dialog reaches it through the field-backed route. Pinning + // acceptance parity is what stops the two doors drifting into dialects. + it('accepts exactly what the FieldSchema twin accepts, and refuses exactly what it refuses', () => { + const asField = (dependsOn: unknown) => + FieldSchema.safeParse({ name: 'owner', type: 'lookup', reference: 'account', dependsOn }).success; + const asBulkParam = (dependsOn: unknown) => + BulkActionParamSchema.safeParse({ name: 'owner', type: 'lookup', object: 'account', dependsOn }).success; + + const cases: unknown[] = [ + ['account'], + [{ field: 'account' }], + [{ field: 'account', param: 'account_id' }], + ['account', { field: 'region', param: 'region_id' }], + [{ local: 'account' }], + [{ param: 'account_id' }], + 'account', + [42], + ]; + const twin = cases.map(asField); + const bulk = cases.map(asBulkParam); + expect(bulk).toEqual(twin); + // The comparison is only worth anything if both verdicts really occur. + expect(new Set(twin)).toEqual(new Set([true, false])); + }); + }); + + describe('— the close carries the author across, it does not just say no (#18177)', () => { + const paramIssues = (param: Record): string => + reject({ name: 'd', operation: 'update', params: [{ name: 'p', type: 'text', ...param }] }).join('\n'); + + it('renames the ACTION-param spellings of the known divergence onto this surface words', () => { + expect(paramIssues({ helpText: 'x' })).toContain('`helpText` → `help`'); + expect(paramIssues({ defaultValue: 'x' })).toContain('`defaultValue` → `default`'); + expect(paramIssues({ reference: 'sys_user' })).toContain('`reference` → `object`'); + expect(paramIssues({ displayField: 'name' })).toContain('`displayField` → `labelField`'); + }); + + it('answers `field` with the route that does not exist rather than a spelling hint', () => { + const issues = paramIssues({ field: 'owner' }); + expect(issues).toContain('FIELD-BACKED'); + expect(issues).toContain('Declare the param inline'); + }); + + it('sends a param-level `visible` to the DEF, where the predicate is really read', () => { + expect(paramIssues({ visible: 'true' })).toContain('bulkActionDefs[].visible'); + }); + + it('answers the whole widget-config family with ONE prescription, not one per key', () => { + const issues = paramIssues({ min: 1, max: 9, step: 1 }); + const occurrences = issues.split('widget-config keys like').length - 1; + expect(occurrences).toBe(1); + }); + }); }); diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts index 93d64f34045..dfffcbf8101 100644 --- a/packages/spec/src/ui/bulk-action.zod.ts +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -48,13 +48,28 @@ import { FieldType } from '../data/field.zod'; // error for a blank screen. Localizing means declaring a real action and // naming it in `bulkActions`: THAT path runs through the i18n resolver // (`toBulkActionDef`'s `localize`). -// - `params[]` is `.passthrough()`. objectui's `BulkActionParam` declares an -// explicit `[key: string]: unknown` catch-all — widget config forwarded to -// the field renderer as-is (min/max/step/format). Locking it down would -// reject valid config, so declared keys are typed and the rest rides -// through, the same call `dashboard.zod.ts` makes for a widget's `config`. -// - `params[].options[]` is `.passthrough()` TOO — measured, not inherited -// from its parent by symmetry. objectui's option TYPE is closed +// - `params[]` is STRICT (#18177, maintainer ruling batch #146 item 4, +// letter A). It used to be `.passthrough()`, mirroring the +// `[key: string]: unknown` catch-all on objectui's `BulkActionParam` — and +// that made its accept a NULL READING: measured against installed spec +// 17.4.0 it took `zzz_nonsense_key_that_no_producer_emits_8755` in the same +// run that it took `dependsOn`, while `ActionParamSchema` one surface over +// refused both with `unrecognized_keys`. A shape that examines nothing can +// license nothing, so the twins now carry the same strictness. +// ⚠️ What that CHANGED, stated rather than buried: the widget-config keys +// the catch-all forwarded (`min`/`max`/`step`/…) are keys of a FIELD, not +// of a bulk param, and this shape declares none of them — they are refused +// now, with the `BULK_PARAM_WIDGET_CONFIG_KEYS` prescription below naming +// where the vocabulary IS real. A census of authored bulk params over the +// two repos reachable from the landing session found ZERO carrying an +// undeclared key, so no in-corpus configuration stops working; hotcrm was +// NOT REACHABLE for that census and is recorded unmeasured, never clean. +// The one key measured LIVE on this surface is DECLARED rather than +// refused — `dependsOn`, below. Retiring it would delete a capability that +// ships. +// - `params[].options[]` is `.passthrough()` — and since the parent closed it +// is the ONE open level left here. Still measured, never inherited from a +// neighbour by symmetry. objectui's option TYPE is closed // (`Array<{ label; value }>`, `packages/types/src/objectql.ts:271`), but the // type is not what an authored option meets: `bulkParamToField` SPREADS each // entry — `options?.map(o => ({ ...o, value: String(o.value) }))`, @@ -73,7 +88,11 @@ import { FieldType } from '../data/field.zod'; // KNOWN DIVERGENCE, DELIBERATELY NOT FIXED HERE. A bulk param and an action // param are the same idea under different spellings (`help`/`helpText`, // `default`/`defaultValue`, `object`/`reference`, plus `labelField`, which -// `ActionParamSchema` has no counterpart for). objectui already owns a converter +// `ActionParamSchema` has no counterpart for — `displayField` is the FIELD +// spelling of the same idea). Closing this shape turned those from keys that +// rode through and did nothing into RENAMES: each is an `aliases` row below, so +// an author who reaches for the neighbouring surface's word is told the one +// this surface takes. objectui already owns a converter // for the PROMOTED direction (`toBulkParam` in `resolveBulkActions.ts`); // converging the AUTHORED direction means teaching the renderer to run authored // params through it and giving `ActionParamSchema` a `labelField` — a cross-repo @@ -104,18 +123,125 @@ export type BulkActionOperation = z.input; export const BulkActionExecutionSchema = z.enum(['perRecord', 'aggregate']); export type BulkActionExecution = z.input; +/** + * Keys a widget on this path really reads off the field bag — and which this + * shape nevertheless does NOT declare, so their rejection has to carry the + * reason rather than a bare "unknown key". + * + * Measured, not listed from memory: `bulkParamToField` destructures the eleven + * declared keys out and spreads the REST onto the field metadata it hands + * `getLazyFieldWidget`, so any of these reaches a widget that reads it — + * `min`/`max`/`step` (NumberField / SliderField / CurrencyField / PercentField + * / RatingField), `accept`/`maxSize`/`crop`/`capture` (FileField / ImageField), + * `rows` (TextAreaField / RichTextField), `precision`/`scale`, `dimensions` + * (VectorField), `defaultName` (AvatarField), and the picker family + * `descriptionField` / `idField` / `allowCreate` / `lookupColumns` / + * `lookupPageSize` / `lookupFilters` / `picker` / `subtitle` / `avatarField` + * (LookupField, and UserField through it). + * + * ⛔ Reading that as "so declare them" is the move this file does not make. A + * declared key is published contract whose removal costs a full retirement, and + * the census that accompanied the close found NO authored bulk param writing + * any of them — the evidence licenses a loud rejection, not twenty new members. + * `format` earns its absence from the list the same way: the module header used + * to name it beside min/max/step, and the sweep found no FORM widget reading it + * at all. + * + * ⚠️ The prescription deliberately refuses the obvious-sounding remedy. There + * is no field-backed param route on the bulk surface — `toBulkParam` never + * consults the object's field definitions — so "declare it on the field and let + * the dialog inherit" would be a confidently wrong answer, the shape this + * campaign has already shipped more than once. + */ +const BULK_PARAM_WIDGET_CONFIG_KEYS = [ + 'min', 'max', 'step', 'precision', 'scale', 'rows', + 'accept', 'maxSize', 'crop', 'capture', 'dimensions', 'defaultName', + 'descriptionField', 'idField', 'allowCreate', + 'lookupColumns', 'lookupPageSize', 'lookupFilters', 'picker', 'subtitle', 'avatarField', +] as const; + /** * One input collected ONCE by the bulk dialog before the run (never re-prompted * per record). For `operation: 'update'` the collected values ARE the patch * (merged over the def's static `patch`); for an aggregate `custom` def they * ride along as the action's params. * - * `.passthrough()` — see the module header: the renderer's own type declares a - * catch-all for widget config, so the declared keys are typed and extras are - * forwarded. That means a typo'd key here still ships silently; the def LEVEL - * is where strictness buys something, and this level is where it would lie. + * STRICT since #18177 — see the module header. An unknown key is refused by + * name, carrying either the rename or the prescription that fixes it, exactly + * as on `ActionParamSchema`. The sentence this replaced said strictness "would + * lie" at this level; the measurement said the opposite — the OPEN shape was + * the lie, because it accepted a nonsense key and `dependsOn` in one breath and + * could therefore license neither. */ -export const BulkActionParamSchema = lazySchema(() => z.object({ +export const BulkActionParamSchema = lazySchema(() => strictObject( + { + surface: 'this bulk action param', + aliases: { + // The KNOWN DIVERGENCE pairs from the module header, in the direction an + // author actually slips: they are writing an ACTION param (or a FIELD) + // and reaching for its word. `toBulkParam` maps the same three when it + // promotes an action param, so the two directions now agree. + helpText: 'help', + description: 'help', + defaultValue: 'default', + reference: 'object', + referenceTo: 'object', + // `labelField` is this surface's name for the picker's option label; + // `FieldSchema` and objectui's picker both spell it `displayField`. + displayField: 'labelField', + title: 'label', + }, + guidance: { + field: + '`field` declares a FIELD-BACKED param, and the bulk surface has no such route: ' + + '`resolveActionParams` consults the object\'s field definitions for the single-record ' + + 'dialog, `resolveBulkActions`\'s `toBulkParam` never does. Declare the param inline ' + + 'instead — `name` + `type`, plus `object` (and optionally `labelField`) for a picker.', + objectOverride: + '`objectOverride` belongs to a field-backed ACTION param, which names the object owning ' + + 'the referenced field. A bulk param is always inline; the object a picker searches is ' + + '`object`.', + visible: + '`visible` on a bulk param has no reader — the dialog renders every param it is given. ' + + 'The per-record eligibility predicate belongs on the DEF (`bulkActionDefs[].visible`), ' + + 'where it gates the button and narrows the run; a per-OPTION rule goes on ' + + '`options[].visibleWhen`.', + visibleWhen: + '`visibleWhen` is a per-OPTION key, not a param one: write it inside `options[]`, where ' + + 'the select/multiselect/radio/checkbox widgets narrow the offered set against the ' + + 'dialog\'s own in-progress values. To gate the whole button, use the def\'s `visible`.', + carryOver: + '`carryOver` is an ACTION-param contract (seed from the current row, render read-only, ' + + 'submit verbatim). A bulk dialog runs over a SELECTION and holds no single row to seed ' + + 'from, so there is nothing for it to carry over. Put a fixed value in the def\'s ' + + '`patch` instead, which is merged under the collected params.', + defaultFromRow: + '`defaultFromRow` prefills an ACTION param from the current row. A bulk dialog has a ' + + 'selection, not a row — use `default` for a fixed prefill, or the def\'s `patch` for a ' + + 'value the user should not see.', + requiresFeature: + '`requiresFeature` is the ACTION param\'s capability sugar, lowered into `visible` at ' + + 'parse time. This shape has no `visible` to lower into; gate the whole button with the ' + + 'def\'s `visible` (`features.x`) or its `requiredPermissions`.', + }, + guidanceSets: [{ + name: 'BULK_PARAM_WIDGET_CONFIG_KEYS', + keys: BULK_PARAM_WIDGET_CONFIG_KEYS, + prescription: + 'widget-config keys like `min` / `max` / `step` / `accept` / `lookupFilters` are keys of a ' + + 'FIELD (`FieldSchema`, `data/field.zod.ts`), not of a bulk action param — this shape ' + + 'declares none of them. Until it was closed they rode through onto the renderer\'s field ' + + 'bag and whichever widget read one honoured it; that door is shut, so the value is ' + + 'refused rather than silently forwarded. ⛔ Declaring the key on the object\'s FIELD does ' + + 'not reach this dialog either: the bulk surface has no field-backed param route. Remove ' + + 'the key, and open an issue if a bulk param genuinely needs it declared here.', + }], + history: + 'Until this shape was closed, `params[]` was `.passthrough()` — every unknown key rode through ' + + 'onto the renderer\'s field bag, so a mis-spelled widget config shipped as a control that ' + + 'quietly ignored it, and a nonsense key parsed exactly as cleanly as a real one.', + }, + { name: z.string().min(1).describe('Param key — becomes params[name] in the patch / action params bag.'), label: z.string().optional().describe('Field label in the dialog. Plain string: an authored def is not i18n-resolved (see module header).'), help: z.string().optional().describe('Help text under the field. (An ActionParam spells this `helpText` — known divergence, module header.)'), @@ -130,7 +256,45 @@ export const BulkActionParamSchema = lazySchema(() => z.object({ labelField: z.string().optional().describe('Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id).'), multiple: z.boolean().optional().describe('Allow picking multiple values — the param value becomes an array and is written to the patch as-is.'), placeholder: z.string().optional().describe('Placeholder text.'), -}).passthrough()); + + /** + * Cascade binding — the ONE key this close DECLARES rather than refuses + * (#18177, ruling batch #146 item 4 letter A). + * + * Shape and description mirror the single-record twin. ⚠️ That twin is + * `FieldSchema.dependsOn` (`data/field.zod.ts`), NOT `ActionParamSchema`, + * which declares no `dependsOn` at all: the single-record dialog reaches the + * key through the FIELD-BACKED route (`resolveActionParams` resolves the + * object's field definitions), which is the very route the bulk surface does + * not have. So one vocabulary, two doors — and on this door the key has to be + * written on the param itself. + * + * Live on BOTH widget families reachable from the bulk dialog, measured on + * the renderer rather than inferred from this schema: + * - the OPTION family reads `field?.dependsOn` and gates/refreshes the + * offered set through `useCascadingOptions` (`SelectField`, + * `MultiSelectField`, `RadioField`, `CheckboxesField`); + * - the reference-bearing PICKER family reads the same key off the same bag + * as `cascadeMeta?.dependsOn` and lowers it into a hard candidate filter + * (`LookupField`, and `UserField` through it). + * + * It reaches them because `bulkParamToField` does not destructure it out — it + * rides the `...extra` spread onto the field metadata. That was already true + * while this shape was open, which is why the accept could not be read as a + * licence and the key could not be retired either: an ablation removing it + * from the spread reddened 7 of 12 cases in objectui. + */ + dependsOn: z.array(z.union([z.string(), strictObject({ + surface: 'this dependsOn entry', + history: + 'Until this shape was closed these were dropped silently — the entry still parsed, so a ' + + 'mis-spelled binding left the picker ungated and unscoped.', + aliases: { name: 'field', fieldName: 'field', local: 'field', remote: 'param', remoteField: 'param', key: 'param' }, + }, { + field: z.string(), + param: z.string().optional(), + })])).optional().describe("Declares that this param's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; {field,param} when the remote filter key differs — the {field,param} form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. On a BULK param the record is the dialog's own in-progress param values — a bulk run holds a selection, not a row — so a binding names a SIBLING PARAM of the same def."), +})); export type BulkActionParam = z.input; /**