Skip to content

Commit 837234d

Browse files
committed
spec(ui): register the ADR-0087 entry and changeset for the bulk-param close
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5021dfa commit 837234d

11 files changed

Lines changed: 539 additions & 86 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING for authored metadata** — `BulkActionParamSchema` is strict, matching its single-record twin `ActionParamSchema`, and declares `dependsOn` (#18177, decision batch #146 item 4, letter A).
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered ui-bulk-action-param-unknown-keys-refused -->
10+
11+
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:
12+
13+
| | positive control (minimal valid) | negative control (nonsense key) | subject (`dependsOn`) |
14+
| --- | --- | --- | --- |
15+
| `BulkActionParamSchema` | parses | **ACCEPTED** | accepted |
16+
| `ActionParamSchema` | parses | refused `unrecognized_keys` | refused `unrecognized_keys` |
17+
18+
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.
19+
20+
The maintainer's ruling: 「Breaking for authored metadata」, one-shot — no grace window, no dual spelling.
21+
22+
### `dependsOn` is DECLARED, not refused — and needs no edit
23+
24+
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.
25+
26+
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.
27+
28+
```ts
29+
params: [
30+
{ name: 'account', type: 'lookup', object: 'showcase_account' },
31+
{ name: 'contact', type: 'lookup', object: 'showcase_contact', dependsOn: ['account'] },
32+
{ name: 'owner', type: 'lookup', object: 'sys_user',
33+
dependsOn: [{ field: 'account', param: 'account_id' }] }, // remote key differs
34+
]
35+
```
36+
37+
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**.
38+
39+
### Migration — FROM → TO
40+
41+
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.
42+
43+
| You wrote on a bulk param | Write instead |
44+
| --- | --- |
45+
| `helpText: '…'` | `help: '…'` |
46+
| `defaultValue: x` | `default: x` |
47+
| `reference: 'sys_user'` | `object: 'sys_user'` |
48+
| `displayField: 'name'` | `labelField: 'name'` |
49+
| `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 |
50+
| `visible: '…'` on the param | move the predicate to the DEF (`bulkActionDefs[].visible`), which gates the button and narrows the run per record |
51+
| `visibleWhen: '…'` on the param | it is a per-**option** key — write it inside `options[]` |
52+
| `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` |
53+
| `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 |
54+
55+
### ⚠️ The widget-config family really was honoured, and really is refused now
56+
57+
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.
58+
59+
⛔ **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.
60+
61+
**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.
62+
63+
### What is deliberately NOT closed
64+
65+
`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.
66+
67+
⛔ No renderer is edited and no key is removed from any other shape. `BulkActionDefSchema` was already strict and is untouched.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const result = BulkActionDefSchema.parse(data);
4646
| **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). |
4747
| **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: ['<name>']` instead. |
4848
| **patch** | `Record<string, any>` | 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. |
49-
| **params** | `({ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … } & Record<string, any>)[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
49+
| **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. |
5050
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
5151
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
5252
| **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);
6969
| **labelField** | `string` | optional | Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id). |
7070
| **multiple** | `boolean` | optional | Allow picking multiple values — the param value becomes an array and is written to the patch as-is. |
7171
| **placeholder** | `string` | optional | Placeholder text. |
72+
| **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. |
7273

7374

7475
---
@@ -111,6 +112,7 @@ const result = BulkActionDefSchema.parse(data);
111112
| **labelField** | `string` | optional | Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id). |
112113
| **multiple** | `boolean` | optional | Allow picking multiple values — the param value becomes an array and is written to the patch as-is. |
113114
| **placeholder** | `string` | optional | Placeholder text. |
115+
| **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. |
114116

115117
### Allowed Values: `BulkActionParam.type`
116118

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,7 +1066,7 @@ View filter rule
10661066
| **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). |
10671067
| **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: ['<name>']` instead. |
10681068
| **patch** | `Record<string, any>` | 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. |
1069-
| **params** | `({ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … } & Record<string, any>)[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
1069+
| **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. |
10701070
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
10711071
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
10721072
| **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. |
@@ -1465,7 +1465,7 @@ View filter rule
14651465
| **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). |
14661466
| **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: ['<name>']` instead. |
14671467
| **patch** | `Record<string, any>` | 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. |
1468-
| **params** | `({ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … } & Record<string, any>)[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
1468+
| **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. |
14691469
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
14701470
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
14711471
| **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. |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 449 |
24+
| Object sites in them | 450 |
2525
| Still-open (strip) sites | 126 |
2626
| Files carrying at least one | 22 |
2727

@@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th
4444

4545
| Dir | Sites | strict | passthrough | catchall | strip |
4646
|---|---|---|---|---|---|
47-
| `ui/` | 175 | 165 | 3 | 0 | 7 |
47+
| `ui/` | 176 | 167 | 2 | 0 | 7 |
4848
| `data/` | 159 | 76 | 1 | 0 | 82 |
4949
| `automation/` | 68 | 43 | 0 | 1 | 24 |
5050
| `security/` | 20 | 7 | 0 | 0 | 13 |
5151
| `studio/` | 27 | 27 | 0 | 0 | 0 |
52-
| **total** | **449** | **318** | **4** | **1** | **126** |
52+
| **total** | **450** | **320** | **3** | **1** | **126** |
5353

5454
## File-level triage — site counts
5555

@@ -64,7 +64,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
6464
| `action-params.zod.ts` | 1 |
6565
| `action.zod.ts` | 9 |
6666
| `app.zod.ts` | 18 |
67-
| `bulk-action.zod.ts` | 3 |
67+
| `bulk-action.zod.ts` | 4 |
6868
| `chart.zod.ts` | 8 |
6969
| `component.zod.ts` | 46 |
7070
| `dashboard.zod.ts` | 11 |
@@ -76,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
7676
| `sharing.zod.ts` | 1 |
7777
| `view.zod.ts` | 61 |
7878
| `widget.zod.ts` | 1 |
79-
| **total** | **175** |
79+
| **total** | **176** |
8080

8181
### `data/` — sites
8282

@@ -155,15 +155,15 @@ over it is here.
155155

156156
### `ui/` — open
157157

158-
**7 strip of 175**, in 4 file(s).
158+
**7 strip of 176**, in 4 file(s).
159159

160160
| File | Strip | Sites |
161161
|---|---|---|
162162
| `action-params.zod.ts` | 1 | 1 |
163163
| `app.zod.ts` | 1 | 18 |
164164
| `view.zod.ts` | 4 | 61 |
165165
| `widget.zod.ts` | 1 | 1 |
166-
| **total** | **7** | **175** |
166+
| **total** | **7** | **176** |
167167

168168
| Bucket | Sites |
169169
|---|---|

0 commit comments

Comments
 (0)