Skip to content

Commit adabccf

Browse files
os-steveclaude
andauthored
spec(ui): BulkActionParamSchema is strict and declares dependsOn (#19090)
Fixes #18177 Clause-②: yes (narrowing) Executes decision batch #146 item 4, **letter A** — maintainer 「146 同意」 2026-09-17T13:16Z. `BulkActionParamSchema` becomes strict like its twin and declares the key measured live on the surface; route B is not built, route C is not kept. **Changeset carrier:** `.changeset/18177-bulk-action-param-strict.md` — `@objectstack/spec` **minor**, body carrying 「Breaking for authored metadata」. **ADR-0087 disposition:** `registered ui-bulk-action-param-unknown-keys-refused` — a D3 structured TODO, not a D2 conversion, for the reason the majors-15/16/17 strictness entries give: an arbitrary unknown key has no mapping target. --- ## 1 — Measure first (the ruling's step 1) ### 1a. Which keys the bulk dialog reads off a bulk param after the spread **Instrument.** `bulkParamToField` destructures the eleven declared keys out and spreads the rest onto the field metadata handed to `getLazyFieldWidget`, so the question is: which keys does a widget read off that bag? Enumerated by scanning every form-widget module `getLazyFieldWidget` can return — `objectui@3e4f6324f7`, `packages/fields/src/widgets/**` (74 non-test modules) — for property reads off the `field` prop, following the local aliases those modules assign it (`const config = field as any`, and the chained `lookupField` → `fieldMeta` → `cascadeMeta` unwrap in `LookupField`). **Firing control (so a zero would be a reading).** The positive control is `dependsOn`: the scan returns it at 6 sites across 5 modules, and each was read by hand to confirm it is live code and not a comment. The negative probe (`zzz_nonsense_key_that_no_producer_emits_8755`) returns nothing. **Instrument's reachable radius, and a known target outside it.** The radius is `packages/fields/src/widgets/**` in objectui. It does **not** reach `packages/fields/src/index.tsx`, and a known target lives there: `buildValidationRules` reads `field.min` / `field.max` / `field.pattern` / `field.required_message` and more. That function is excluded deliberately, not by accident — it is the react-hook-form path the object FORM uses, and the bulk dialog does not go through it (`BulkActionDialog` renders the widget directly). Its keys are therefore not evidence about this surface. A first pass of the scan that did include `index.tsx` also over-reported `startsWith` (a string method, not a field key), which is why the alias-following pass was read by hand rather than trusted. **Result — `dependsOn` is live on BOTH widget families reachable from the dialog:** | family | reader | what it does with the key | | --- | --- | --- | | option widgets — `SelectField`, `MultiSelectField`, `RadioField`, `CheckboxesField` | `field?.dependsOn` | gates and re-resolves the offered set through `useCascadingOptions` | | reference-bearing pickers — `LookupField`, and `UserField` through it | `cascadeMeta?.dependsOn` | lowers it into a hard candidate filter and gates the trigger while a named parent is empty | **And a long tail of widget-config keys is read off the same bag** — `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 knobs `descriptionField` / `idField` / `allowCreate` / `lookupColumns` / `lookupPageSize` / `lookupFilters` / `picker` / `subtitle` / `avatarField` (LookupField). ⭐ **`format` is not among them**, although the module header used to name it beside min/max/step: no form widget reads it. That discrimination is what makes the list a measurement rather than a transcription of the header. ### 1b. Census of authored bulk params for keys the schema does not declare **Instrument.** Bracket-matches every `bulkActionDefs` array in a tree, extracts each `params[]` object literal and lists its top-level keys. **Firing control:** a planted fixture carrying `dependsOn` and the nonsense key — the instrument reports both and leaves the eleven declared keys unflagged. | tree | bulk-param literals | carrying an undeclared key | | --- | --- | --- | | `objectstack-ai/objectstack` @ `176b03582e` | 7 | **0** | | `objectstack-ai/objectui` @ `3e4f6324f7` | 3 | **0** | | `objectstack-ai/hotcrm` | **NOT MEASURED** | **NOT MEASURED** | ⚠️ **The hotcrm leg is NOT MEASURED, and is not to be read as clean.** That repository is not reachable from this session, and ⛔ nothing here infers its contents from the excerpts quoted on the card. The ruling asked for a census across the four repos and hotcrm; what was reachable is the two rows above. The migration entry records the same boundary so an upgrader does not inherit the result. **Instrument's radius here too:** it finds params written as object literals inside a `bulkActionDefs` array. A param assembled in a variable and spread in would be outside it. No such site was seen, but that is an absence the instrument cannot certify. ⇒ **No authored unknown key was found, so no ADR-0087 *conversion* entry is owed and there is no stop-and-report.** The registered entry is the D3 structured TODO for the narrowing itself. --- ## 2 — What the change is `BulkActionParamSchema` moves from `z.object({…}).passthrough()` to `strictObject({…})`, and declares `dependsOn`. The rejection is curated rather than bare: - **aliases** — the known-divergence spellings now RENAME instead of riding through: `helpText` → `help`, `description` → `help`, `defaultValue` → `default`, `reference` → `object`, `referenceTo` → `object`, `displayField` → `labelField`, `title` → `label`. These are the same three mappings `toBulkParam` performs when it promotes an ACTION param, so the authored and promoted directions now agree. - **guidance** — `field`, `objectOverride`, `visible`, `visibleWhen`, `carryOver`, `defaultFromRow`, `requiresFeature`, each answered with the layer that really owns it. ⛔ None of them promises the field-backed route, because the bulk surface does not have one — that would be the confidently-wrong prescription this campaign has shipped before. - **guidanceSet `BULK_PARAM_WIDGET_CONFIG_KEYS`** — one prescription for the whole measured widget-config family, naming `FieldSchema` as the shape those keys are real on, and saying in as many words that declaring the key on the object's FIELD does not reach this dialog either. ### Why the declare set is `dependsOn` and not the whole measured tail The tail is measurably READ, so declaring it would be defensible on that half alone. It is not declared because the other half is missing: the census found no author writing one, and a declared key is published contract whose removal costs a full retirement kit, while an over-strict refusal costs one card. The asymmetry decides it. The measured tail is written into the file beside the guidanceSet so the next reader has the evidence without re-deriving it, and the residual question is filed rather than guessed — see Acceptance notes. **⚠️ The cost is real and is not buried:** those keys were honoured, and they are refused now. That is the behaviour change the ruling's own words priced in («a behaviour change for every existing author of a bulk param, not just for this key»), one-shot, no grace window, no dual spelling. The census measures the in-corpus breakage at zero. ### What is deliberately NOT closed `params[].options[]` stays `.passthrough()`. Its openness rests on its own 2026-08-03 measurement (the option entries are spread verbatim into the field metadata, where the widgets read `color` / `icon` / `disabled` / `visibleWhen`), which this change does not disturb. Closing it by symmetry with its parent would delete widget config the renderer honours — the same defect this PR closes one level up. The declared `{ label, value }` pair is still type-checked. --- ## 3 — A brief premise corrected on measurement The dispatch named `packages/spec/src/ui/action.zod.ts` as «the twin whose shape and `.describe()` text you must match». Measured: **`ActionParamSchema` declares no `dependsOn` at all.** The single-record dialog reaches the key through the field-backed route (`resolveActionParams` resolves the object's field definitions), so the spec's only declaration of this key is **`FieldSchema.dependsOn`** (`packages/spec/src/data/field.zod.ts`) — which is also the spelling the card itself names as the one objectui was ruled to honour. So `action.zod.ts` is the twin for **strictness**, and `FieldSchema` is the twin for **this key's shape and description**. Both halves are honoured: the member is byte-for-byte the field-level union (`string` or a strict `{ field, param }` entry, same alias table), and the description is the field-level text with ONE sentence appended — a bulk run holds a selection and not a row, so «other field(s) on the same record» had to say what the record is here (the dialog's own in-progress param values, i.e. a sibling param of the same def). ⛔ `action.zod.ts` is not edited. A parity pin (`accepts exactly what the FieldSchema twin accepts, and refuses exactly what it refuses`, 8 cases, asserted equal as a vector and asserted to contain both verdicts) is what stops the two doors drifting into dialects. --- ## 4 — Verification Run against `837234d86b`, this branch's final commit. | leg | command | result | | --- | --- | --- | | build | `pnpm --filter @objectstack/spec build` | exit 0 | | typecheck + test | `pnpm --filter @objectstack/spec typecheck && pnpm --filter @objectstack/spec test` | exit 0 — **492 test files, 14493 tests passed** | | generated artifacts | `pnpm --filter @objectstack/spec check:generated` | exit 0, all 16 up to date after regeneration | | ADR-0087 | `node scripts/check-adr-0087-registration.mjs --base origin/main` | exit 0 — `[BREAKING+clause-②-narrowing] registered ui-bulk-action-param-unknown-keys-refused (new here)` | | eslint, whole repo | `pnpm lint` (= `eslint . --no-inline-config`) | exit 0 — no narrowing claimed, the full run fits | | control bytes | `grep -naP` over all 14 changed paths, plus `pnpm check:nul-bytes` | no match / exit 0 | | derived gate families | `node scripts/pm/dispatch-gates.mjs` over the real change set, reconciled with `--ran` carrying exit codes | **101 green, 7 NOT MEASURED** | **The 7 NOT MEASURED, every one a `PREREQUISITE NOT MET` refusal that needs a repo-wide build this lane does not own** (exit 3, except the last which exits 1 and says the same thing in words — recorded here rather than counted as a failure): `check:doc-formula-expressions`, `check:doc-security-posture`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`, `check:skill-examples`. ⛔ None of them read anything about this diff; they are declared to CI, not skipped quietly. **Regenerated, never hand-edited:** `authorable-surface/ui.json` (gains `ui/BulkActionParam:dependsOn`), `api-surface-declarations/*.txt`, `content/docs/references/ui/{bulk-action,view}.mdx`, the strictness-ledger counts, and `migrations/registry.ts` (from the new one-file entry, via `gen:migration-registry`). ⛔ Nothing was typed between the generated markers. `authorable-surface.base.json` is unchanged, as expected — only `gen:authorable-surface-base` writes it. **Strictness ledger moves the right way:** `ui/` passthrough 3 → 2, strict 165 → 167; repo total strict 318 → 320, passthrough 4 → 3. **No in-repo consumer of the narrowed type.** `BulkActionParam` loses its index signature when the shape closes. Measured: no file outside `packages/spec/src` imports that type (`packages/cli` and `packages/spec/scripts` mention it in prose only), so no consumer typecheck is owed. `packages/cli typecheck` was attempted and refuses on unbuilt workspace dependencies — the AGENTS.md section-9 stale-closure signature, unrelated to this diff and left to CI. --- ## Acceptance notes Everything below was found on the way and is deliberately NOT fixed here. 1. **To file — should the measured widget-config family become declared on a bulk param?** After this PR, `min` / `max` / `step` / `precision` / `scale` / `rows` / `accept` / `maxSize` and the picker knobs are refused at parse while the widget one seam over would still honour them, and there is no field-backed route to reach the dialog by. That is a real authoring gap: an author reading the renderer's vocabulary writes a key the runtime now rejects. Sized and located by the measurement in §1a. Dedupe words: `BulkActionParam`, widget config, `min`/`max`/`step`, `bulkParamToField` spread, field-backed bulk param. 2. **To file — the objectui-side half of this landing.** `objectui`'s `packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx` leg B pins that `BulkActionParamSchema` ACCEPTS a nonsense key, and `packages/types/src/__tests__/bulk-action-param-options.test.ts:139` parses an authored param through the same schema. Both are correct against installed 17.4.0 and both turn red the day objectui's spec pin crosses this release; the first has to be re-judged into a refusal pin the way this PR re-judged its own. Nothing here breaks objectui's BUILD — no export is removed or renamed — so this is a coordination note, not a Post-Task-Checklist-4 blocker. Dedupe words: objectui, `bulkLookupDependsOnReach-8755`, leg B null reading, spec pin bump, bulk param strict. 3. **Noted, not filed:** `packages/spec/src/shared/union-author-message-pins.test.ts` carries a hand-maintained table of string-or-object union sites, and this PR adds one (`BulkActionParam.dependsOn`). The file says out loud that nothing mechanically holds that table equal to the tree and that a standing re-scan «is deliberately left to its own card», so the gap is already recorded there. The new site's rendered message is pinned in this PR's own sibling test instead (surface phrase, rename arrow, and the string arm's kind mismatch asserted absent). Carrier: the next PR that touches that table, or the standing-guard card the file already names. 4. **Noted, not filed:** the module header's claim that the catch-all forwarded `min/max/step/format` was 3-for-4 — no form widget reads `format` on this path. The header is corrected in this PR rather than filed, because the sentence lives in the file being edited. Carrier: none needed. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fbc12be commit adabccf

9 files changed

Lines changed: 551 additions & 29 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
@@ -1071,7 +1071,7 @@ View filter rule
10711071
| **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). |
10721072
| **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. |
10731073
| **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. |
1074-
| **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. |
1074+
| **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. |
10751075
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
10761076
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
10771077
| **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
14731473
| **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). |
14741474
| **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. |
14751475
| **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. |
1476-
| **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. |
1476+
| **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. |
14771477
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
14781478
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
14791479
| **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 | 451 |
24+
| Object sites in them | 452 |
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/` | 177 | 166 | 4 | 0 | 7 |
47+
| `ui/` | 178 | 168 | 3 | 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** | **451** | **319** | **5** | **1** | **126** |
52+
| **total** | **452** | **321** | **4** | **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` | 48 |
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** | **177** |
79+
| **total** | **178** |
8080

8181
### `data/` — sites
8282

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

156156
### `ui/` — open
157157

158-
**7 strip of 177**, in 4 file(s).
158+
**7 strip of 178**, 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** | **177** |
166+
| **total** | **7** | **178** |
167167

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

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@
158158
"ui/BulkActionDef:variant",
159159
"ui/BulkActionDef:visible",
160160
"ui/BulkActionParam:default",
161+
"ui/BulkActionParam:dependsOn",
161162
"ui/BulkActionParam:help",
162163
"ui/BulkActionParam:label",
163164
"ui/BulkActionParam:labelField",

0 commit comments

Comments
 (0)