Skip to content

Commit 2e284b2

Browse files
fix(spec): make the bulk-action option item's openness deliberate, not accidental (#4001) (#4909)
* fix(spec): make the bulk-action option item's openness deliberate, not accidental (#4001) `BulkActionParamSchema`'s `options[]` entry was bare default-strip while the strictness ledger's prose called it and its parent "both deliberately open" — one intent, two postures, found by the #4001 2026-08-03 re-measure (#4852). Per the maintainer's verdict A the code moves to the prose: `.passthrough()` on the option item. Verified before writing, since the prose itself had just been proven able to drift — 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 every entry (`packages/plugin-grid/src/components/bulkParamToField.ts:131`) into the field metadata, whose vocabulary is `SelectOptionMetadata` (`packages/types/src/field-types.ts:288`) — `color` / `icon` / `disabled` / `visibleWhen` beyond the pair, and read (`packages/fields/src/index.tsx:1089`). So the strip was deleting widget config the renderer would have honoured. The new test states the openness is deliberate and names the evidence, so a future strict-ification sweep stops there instead of "fixing" it. Ledger: `ui/bulk-action.zod.ts` reaches zero strip sites, so its remaining-strip row is deleted and the header arithmetic reconciled (124 → 123 of 198). Both directions of that gate were driven red before the green was believed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 * docs(spec): keep the option shape readable after the passthrough (#4001) `gen:docs` renders a passthrough object inside an array as `Record<string, any>[]`, so the reference page lost the `{ label, value }` shape the moment the entry opened — an author-facing surface reading "shapeless" for something that still has two required keys, which is the strip-era smell pointed the other way. The declared pair now lives in the key's own `.describe()`, which the generator renders verbatim, along with WHY the entry is open and which extra keys the widget actually reads. Regenerated `content/docs/references/ui/bulk-action.mdx` accordingly (the one artifact `check:generated` proved stale — not the set). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19cb31c commit 2e284b2

5 files changed

Lines changed: 112 additions & 7 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): stop silently stripping widget config off a bulk-action param option (#4001)
6+
7+
`BulkActionParamSchema`'s `options[]` entry is now `.passthrough()`, matching its
8+
parent. **Behaviour change, loosening only**: keys other than `label` / `value` on
9+
an option used to be *removed at parse* and are now preserved. Nothing that parsed
10+
before stops parsing, and no key changes meaning — an authored option simply keeps
11+
what it was written with.
12+
13+
Concretely, this used to happen without a warning:
14+
15+
```ts
16+
// authored
17+
options: [{ label: 'In Review', value: 'in_review', color: '#8B5CF6', icon: 'eye' }]
18+
// parsed, BEFORE
19+
options: [{ label: 'In Review', value: 'in_review' }]
20+
```
21+
22+
`color` and `icon` are not decoration the renderer ignores. objectui's
23+
`bulkParamToField` spreads every option entry into the field metadata
24+
(`packages/plugin-grid/src/components/bulkParamToField.ts:131`), where the widget
25+
vocabulary is `SelectOptionMetadata` (`packages/types/src/field-types.ts:288`) —
26+
`color`, `icon`, `disabled` and `visibleWhen` beyond the declared pair, and read
27+
(`option?.color`, `packages/fields/src/index.tsx:1089`). So the strip deleted
28+
config that would otherwise have rendered, on the authoring side, invisibly.
29+
30+
Nothing to migrate. If you dropped option colors/icons because "the spec ate them",
31+
they work now. The strictness ledger's prose already described this level as
32+
deliberately open while only the parent schema said so in code; the code now says
33+
it too, which is the part a machine can check.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ const result = BulkActionDefSchema.parse(data);
9595
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` || Field widget to render, from the standard field-type vocabulary (text/number/select/lookup/date/…). |
9696
| **required** | `boolean` | optional | Blocks the Confirm button until a value is present. |
9797
| **default** | `any` | optional | Value applied when the dialog opens. (An ActionParam spells this `defaultValue`.) |
98-
| **options** | `{ label: string; value: string \| number \| boolean }[]` | optional | Static options for select-style widgets. |
98+
| **options** | `Record<string, any>[]` | optional | Static options for select-style widgets. Each entry is `{ label, value }` plus any extra widget config — the entry is open (`.passthrough()`) because the renderer forwards unknown option keys to the field widget, which reads `color` / `icon` / `disabled` / `visibleWhen` beyond the declared pair. |
9999
| **object** | `string` | optional | Target object for a `lookup` widget. (An ActionParam spells this `reference`.) |
100100
| **labelField** | `string` | optional | Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id). |
101101
| **multiple** | `boolean` | optional | Allow picking multiple values — the param value becomes an array and is written to the patch as-is. |

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,7 @@ not verdicts).
488488
|---|---|---|---|
489489
| `action.zod.ts` | 8 | authorable | param schema strict (#3746); remaining blocks ride later steps. **9 → 8 at the #4001 re-measurement** — no schema changed: the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code |
490490
| `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below |
491-
| `bulk-action.zod.ts` | 3 | authorable | **strict as of #4457**`BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open**: objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so `.passthrough()` is the honest mirror and strictness there would reject valid config — same call as `dashboard.zod.ts`'s widget `config`. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached |
491+
| `bulk-action.zod.ts` | 3 | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open** and both now `.passthrough()` — the param because objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so passthrough is the honest mirror and strictness would reject valid config (same call as `dashboard.zod.ts`'s widget `config`); the OPTION ENTRY on separate measured evidence, since its objectui type is closed and only the runtime path is open — `bulkParamToField` spreads each entry (`plugin-grid/src/components/bulkParamToField.ts:131`) into `SelectOptionMetadata` (`types/src/field-types.ts:288`), which declares and reads `color` / `icon` / `disabled` / `visibleWhen`. **This row said "both deliberately open" while only the parent was `passthrough`** — one intent, two postures, caught by the 2026-08-03 re-measure and closed by the ruling's verdict A (make the code match the prose). The lesson is the campaign's own: prose in this ledger is not a posture reading, which is why the remaining-strip map is gated and this column is not. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached |
492492
| `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) |
493493
| `theme.zod.ts` | 14 | authorable (p) | authored themes |
494494
| `app.zod.ts` | 18 | authorable | **strict as of #4001 PR B**`AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first |
@@ -625,7 +625,7 @@ is complete and so nobody re-triages them from scratch next batch.
625625

626626
**Authorable strip in `automation/`: 41 of 67.** This is the ruling's "known main body".
627627

628-
#### `ui/`124 strip of 198
628+
#### `ui/`123 strip of 198
629629

630630
| File | Strip | Sites | Class | Batch |
631631
|---|---|---|---|---|
@@ -647,13 +647,23 @@ is complete and so nobody re-triages them from scratch next batch.
647647
| `sharing.zod.ts` | 2 | 2 | authorable (p) | `SharingConfig` / `EmbedConfig` |
648648
| `action.zod.ts` | 1 | 8 | authorable | `ActionParamSchema.options` — a plain `{ label, value }` pair; the cheapest win in the directory |
649649
| `app.zod.ts` | 1 | 18 | verify | `BaseNavItemSchema` — the base the strict discriminated-union members extend. Closing a base that is `.extend()`ed is the #4001 trap that bit `view` (finding 16); confirm the members' strictness is not already covering it before touching |
650-
| `bulk-action.zod.ts` | 1 | 3 | open | `BulkActionParamSchema.options`. ⚠️ **The triage row calls both this and its parent "deliberately open", but only the PARENT is `passthrough` — this one is plain strip.** Same intent, two postures; decide which the intent actually was |
651650
| `notification.zod.ts` | 1 | 1 | authorable (p) | `NotificationActionSchema` |
652651

653-
**Authorable strip in `ui/`: 123 of 124**everything except `bulk-action.zod.ts`'s
654-
`options`, which is `open`. Of those 123, `app.zod.ts`'s single site is held pending
652+
**Authorable strip in `ui/`: 123 of 123**every remaining strip site in this
653+
directory is authorable. Of those 123, `app.zod.ts`'s single site is held pending
655654
the finding-16 `.extend()` check rather than counted as ready.
656655

656+
The one `open` site this directory carried is **gone, and not by being closed**:
657+
`bulk-action.zod.ts`'s `BulkActionParamSchema.options` was the row that read
658+
*"the triage row calls this and its parent deliberately open, but only the PARENT
659+
is `passthrough`"*. The 2026-08-03 ruling settled the intent as **open** and the
660+
fix was to make the code say so — `.passthrough()` on the option item, so the
661+
posture and the prose agree. It leaves this map the way a resolved row is
662+
supposed to: the file now has **0** strip sites, so its row is deleted (the
663+
reverse pin above). Worth noting for the next batch that "resolve a row" has two
664+
exits, and the reverse pin cannot tell them apart — only the changeset and the
665+
triage row record which one was taken.
666+
657667
#### `data/` — 121 strip of 162
658668

659669
| File | Strip | Sites | Class | Batch |

packages/spec/src/ui/bulk-action.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,52 @@ describe('BulkActionDefSchema (#4457)', () => {
6464
});
6565
expect(def.params?.[0]).toMatchObject({ min: 1, max: 90, step: 1 });
6666
});
67+
68+
// ── DELIBERATE OPENNESS — DO NOT "FIX" THIS INTO A STRICT SITE ──────────
69+
// This assertion exists to be found by the next strictness sweep (#4001)
70+
// and to stop it. `params[].options[]` is `.passthrough()` ON PURPOSE, on
71+
// measured evidence rather than symmetry with its parent:
72+
//
73+
// - `bulkParamToField` SPREADS every option entry into the field
74+
// metadata — `options?.map(o => ({ ...o, value: String(o.value) }))`,
75+
// objectui `packages/plugin-grid/src/components/bulkParamToField.ts:131`
76+
// - the vocabulary it lands in is `SelectOptionMetadata` (objectui
77+
// `packages/types/src/field-types.ts:288`), which declares `color` /
78+
// `icon` / `disabled` / `visibleWhen` beyond `{ label, value }` and
79+
// reads them (`option?.color`, `packages/fields/src/index.tsx:1089`)
80+
//
81+
// So a strip here does not protect an author from a typo; it deletes
82+
// widget config the renderer would have honoured, silently — exactly the
83+
// failure mode the def LEVEL's strictness exists to catch. Until the
84+
// 2026-08-03 re-measure this level was bare strip while the ledger prose
85+
// already called it open (maintainer verdict A: make the code match).
86+
it('forwards unknown WIDGET config on an OPTION — deliberately open, not an oversight', () => {
87+
const def = ok({
88+
name: 'set_stage',
89+
operation: 'update',
90+
params: [{
91+
name: 'stage',
92+
type: 'select',
93+
options: [
94+
{ label: 'In Review', value: 'in_review', color: '#8B5CF6', icon: 'eye' },
95+
{ label: 'Done', value: 'done', disabled: true, visibleWhen: "current_user.is_admin" },
96+
],
97+
}],
98+
});
99+
const options = def.params?.[0]?.options;
100+
expect(options?.[0]).toMatchObject({ label: 'In Review', value: 'in_review', color: '#8B5CF6', icon: 'eye' });
101+
expect(options?.[1]).toMatchObject({ disabled: true, visibleWhen: 'current_user.is_admin' });
102+
});
103+
104+
it('an option still requires the declared pair to be well-typed', () => {
105+
// Open ≠ shapeless: passthrough forwards UNDECLARED keys, it does not
106+
// relax the two keys the executor and the widget both read.
107+
expect(reject({
108+
name: 'set_stage',
109+
operation: 'update',
110+
params: [{ name: 'stage', type: 'select', options: [{ value: 'done' }] }],
111+
}).join('\n')).toMatch(/params\.0\.options\.0\.label/);
112+
});
67113
});
68114

69115
describe('— a mis-spelled key is an error, not a silent default', () => {

packages/spec/src/ui/bulk-action.zod.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ import { FieldType } from '../data/field.zod';
5353
// the field renderer as-is (min/max/step/format). Locking it down would
5454
// reject valid config, so declared keys are typed and the rest rides
5555
// through, the same call `dashboard.zod.ts` makes for a widget's `config`.
56+
// - `params[].options[]` is `.passthrough()` TOO — measured, not inherited
57+
// from its parent by symmetry. objectui's option TYPE is closed
58+
// (`Array<{ label; value }>`, `packages/types/src/objectql.ts:271`), but the
59+
// type is not what an authored option meets: `bulkParamToField` SPREADS each
60+
// entry — `options?.map(o => ({ ...o, value: String(o.value) }))`,
61+
// `packages/plugin-grid/src/components/bulkParamToField.ts:131` — so every
62+
// extra key survives verbatim into the field metadata, where the widget
63+
// vocabulary is `SelectOptionMetadata`
64+
// (`packages/types/src/field-types.ts:288`): `color` / `icon` / `disabled` /
65+
// `visibleWhen` beyond the pair, and read (`option?.color`,
66+
// `packages/fields/src/index.tsx:1089`). Stripping here therefore DELETES
67+
// authored widget config the renderer would have honoured — the silent
68+
// narrowing this file exists to stop. Until #4001's 2026-08-03 re-measure
69+
// this level was bare strip while the ledger prose called it open: one
70+
// intent, two postures. `passthrough` is that intent in machine-readable
71+
// form; the prose alone had already been proven able to drift.
5672
//
5773
// KNOWN DIVERGENCE, DELIBERATELY NOT FIXED HERE. A bulk param and an action
5874
// param are the same idea under different spellings (`help`/`helpText`,
@@ -109,7 +125,7 @@ export const BulkActionParamSchema = lazySchema(() => z.object({
109125
options: z.array(z.object({
110126
label: z.string().describe('Option label (plain string — not i18n-resolved on this path).'),
111127
value: z.union([z.string(), z.number(), z.boolean()]).describe('Stored value.'),
112-
})).optional().describe('Static options for select-style widgets.'),
128+
}).passthrough()).optional().describe('Static options for select-style widgets. Each entry is `{ label, value }` plus any extra widget config — the entry is open (`.passthrough()`) because the renderer forwards unknown option keys to the field widget, which reads `color` / `icon` / `disabled` / `visibleWhen` beyond the declared pair.'),
113129
object: SnakeCaseIdentifierSchema.optional().describe("Target object for a `lookup` widget. (An ActionParam spells this `reference`.)"),
114130
labelField: z.string().optional().describe('Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id).'),
115131
multiple: z.boolean().optional().describe('Allow picking multiple values — the param value becomes an array and is written to the patch as-is.'),

0 commit comments

Comments
 (0)