From e446c5b6982ae13ff6ad304516c195e197edbc81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:19:25 +0000 Subject: [PATCH 1/2] fix(spec): make the bulk-action option item's openness deliberate, not accidental (#4001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .changeset/bulk-action-option-passthrough.md | 33 +++++++++++++ .../2026-07-unknown-key-strictness-ledger.md | 20 ++++++-- packages/spec/src/ui/bulk-action.test.ts | 46 +++++++++++++++++++ packages/spec/src/ui/bulk-action.zod.ts | 18 +++++++- 4 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 .changeset/bulk-action-option-passthrough.md diff --git a/.changeset/bulk-action-option-passthrough.md b/.changeset/bulk-action-option-passthrough.md new file mode 100644 index 0000000000..43e862eb6e --- /dev/null +++ b/.changeset/bulk-action-option-passthrough.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec): stop silently stripping widget config off a bulk-action param option (#4001) + +`BulkActionParamSchema`'s `options[]` entry is now `.passthrough()`, matching its +parent. **Behaviour change, loosening only**: keys other than `label` / `value` on +an option used to be *removed at parse* and are now preserved. Nothing that parsed +before stops parsing, and no key changes meaning — an authored option simply keeps +what it was written with. + +Concretely, this used to happen without a warning: + +```ts +// authored +options: [{ label: 'In Review', value: 'in_review', color: '#8B5CF6', icon: 'eye' }] +// parsed, BEFORE +options: [{ label: 'In Review', value: 'in_review' }] +``` + +`color` and `icon` are not decoration the renderer ignores. objectui's +`bulkParamToField` spreads every option entry into the field metadata +(`packages/plugin-grid/src/components/bulkParamToField.ts:131`), where the widget +vocabulary is `SelectOptionMetadata` (`packages/types/src/field-types.ts:288`) — +`color`, `icon`, `disabled` and `visibleWhen` beyond the declared pair, and read +(`option?.color`, `packages/fields/src/index.tsx:1089`). So the strip deleted +config that would otherwise have rendered, on the authoring side, invisibly. + +Nothing to migrate. If you dropped option colors/icons because "the spec ate them", +they work now. The strictness ledger's prose already described this level as +deliberately open while only the parent schema said so in code; the code now says +it too, which is the part a machine can check. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 0151f81526..1cc370872e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -488,7 +488,7 @@ not verdicts). |---|---|---|---| | `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 | | `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | -| `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 | +| `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 | | `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) | | `theme.zod.ts` | 14 | authorable (p) | authored themes | | `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. **Authorable strip in `automation/`: 41 of 67.** This is the ruling's "known main body". -#### `ui/` — 124 strip of 198 +#### `ui/` — 123 strip of 198 | File | Strip | Sites | Class | Batch | |---|---|---|---|---| @@ -647,13 +647,23 @@ is complete and so nobody re-triages them from scratch next batch. | `sharing.zod.ts` | 2 | 2 | authorable (p) | `SharingConfig` / `EmbedConfig` | | `action.zod.ts` | 1 | 8 | authorable | `ActionParamSchema.options` — a plain `{ label, value }` pair; the cheapest win in the directory | | `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 | -| `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 | | `notification.zod.ts` | 1 | 1 | authorable (p) | `NotificationActionSchema` | -**Authorable strip in `ui/`: 123 of 124** — everything except `bulk-action.zod.ts`'s -`options`, which is `open`. Of those 123, `app.zod.ts`'s single site is held pending +**Authorable strip in `ui/`: 123 of 123** — every remaining strip site in this +directory is authorable. Of those 123, `app.zod.ts`'s single site is held pending the finding-16 `.extend()` check rather than counted as ready. +The one `open` site this directory carried is **gone, and not by being closed**: +`bulk-action.zod.ts`'s `BulkActionParamSchema.options` was the row that read +*"the triage row calls this and its parent deliberately open, but only the PARENT +is `passthrough`"*. The 2026-08-03 ruling settled the intent as **open** and the +fix was to make the code say so — `.passthrough()` on the option item, so the +posture and the prose agree. It leaves this map the way a resolved row is +supposed to: the file now has **0** strip sites, so its row is deleted (the +reverse pin above). Worth noting for the next batch that "resolve a row" has two +exits, and the reverse pin cannot tell them apart — only the changeset and the +triage row record which one was taken. + #### `data/` — 121 strip of 162 | File | Strip | Sites | Class | Batch | diff --git a/packages/spec/src/ui/bulk-action.test.ts b/packages/spec/src/ui/bulk-action.test.ts index 4cf473ca3a..091f319255 100644 --- a/packages/spec/src/ui/bulk-action.test.ts +++ b/packages/spec/src/ui/bulk-action.test.ts @@ -64,6 +64,52 @@ describe('BulkActionDefSchema (#4457)', () => { }); expect(def.params?.[0]).toMatchObject({ min: 1, max: 90, step: 1 }); }); + + // ── DELIBERATE OPENNESS — DO NOT "FIX" THIS INTO A STRICT SITE ────────── + // This assertion exists to be found by the next strictness sweep (#4001) + // and to stop it. `params[].options[]` is `.passthrough()` ON PURPOSE, on + // measured evidence rather than symmetry with its parent: + // + // - `bulkParamToField` SPREADS every option entry into the field + // metadata — `options?.map(o => ({ ...o, value: String(o.value) }))`, + // objectui `packages/plugin-grid/src/components/bulkParamToField.ts:131` + // - the vocabulary it lands in is `SelectOptionMetadata` (objectui + // `packages/types/src/field-types.ts:288`), which declares `color` / + // `icon` / `disabled` / `visibleWhen` beyond `{ label, value }` and + // reads them (`option?.color`, `packages/fields/src/index.tsx:1089`) + // + // So a strip here does not protect an author from a typo; it deletes + // widget config the renderer would have honoured, silently — exactly the + // failure mode the def LEVEL's strictness exists to catch. Until the + // 2026-08-03 re-measure this level was bare strip while the ledger prose + // already called it open (maintainer verdict A: make the code match). + it('forwards unknown WIDGET config on an OPTION — deliberately open, not an oversight', () => { + const def = ok({ + name: 'set_stage', + operation: 'update', + params: [{ + name: 'stage', + type: 'select', + options: [ + { label: 'In Review', value: 'in_review', color: '#8B5CF6', icon: 'eye' }, + { label: 'Done', value: 'done', disabled: true, visibleWhen: "current_user.is_admin" }, + ], + }], + }); + const options = def.params?.[0]?.options; + expect(options?.[0]).toMatchObject({ label: 'In Review', value: 'in_review', color: '#8B5CF6', icon: 'eye' }); + expect(options?.[1]).toMatchObject({ disabled: true, visibleWhen: 'current_user.is_admin' }); + }); + + it('an option still requires the declared pair to be well-typed', () => { + // Open ≠ shapeless: passthrough forwards UNDECLARED keys, it does not + // relax the two keys the executor and the widget both read. + expect(reject({ + name: 'set_stage', + operation: 'update', + params: [{ name: 'stage', type: 'select', options: [{ value: 'done' }] }], + }).join('\n')).toMatch(/params\.0\.options\.0\.label/); + }); }); describe('— a mis-spelled key is an error, not a silent default', () => { diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts index 616e09d205..fa5c3e1a9e 100644 --- a/packages/spec/src/ui/bulk-action.zod.ts +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -53,6 +53,22 @@ import { FieldType } from '../data/field.zod'; // 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 +// (`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) }))`, +// `packages/plugin-grid/src/components/bulkParamToField.ts:131` — so every +// extra key survives verbatim into the field metadata, where the widget +// vocabulary is `SelectOptionMetadata` +// (`packages/types/src/field-types.ts:288`): `color` / `icon` / `disabled` / +// `visibleWhen` beyond the pair, and read (`option?.color`, +// `packages/fields/src/index.tsx:1089`). Stripping here therefore DELETES +// authored widget config the renderer would have honoured — the silent +// narrowing this file exists to stop. Until #4001's 2026-08-03 re-measure +// this level was bare strip while the ledger prose called it open: one +// intent, two postures. `passthrough` is that intent in machine-readable +// form; the prose alone had already been proven able to drift. // // KNOWN DIVERGENCE, DELIBERATELY NOT FIXED HERE. A bulk param and an action // param are the same idea under different spellings (`help`/`helpText`, @@ -109,7 +125,7 @@ export const BulkActionParamSchema = lazySchema(() => z.object({ options: z.array(z.object({ label: z.string().describe('Option label (plain string — not i18n-resolved on this path).'), value: z.union([z.string(), z.number(), z.boolean()]).describe('Stored value.'), - })).optional().describe('Static options for select-style widgets.'), + }).passthrough()).optional().describe('Static options for select-style widgets.'), object: SnakeCaseIdentifierSchema.optional().describe("Target object for a `lookup` widget. (An ActionParam spells this `reference`.)"), 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.'), From 4fa856ebdcad7a1e9b404a94418e743ebaa273f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:32:21 +0000 Subject: [PATCH 2/2] docs(spec): keep the option shape readable after the passthrough (#4001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gen:docs` renders a passthrough object inside an array as `Record[]`, 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 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- content/docs/references/ui/bulk-action.mdx | 2 +- packages/spec/src/ui/bulk-action.zod.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx index 4728d8e742..928ccf770e 100644 --- a/content/docs/references/ui/bulk-action.mdx +++ b/content/docs/references/ui/bulk-action.mdx @@ -95,7 +95,7 @@ const result = BulkActionDefSchema.parse(data); | **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/…). | | **required** | `boolean` | optional | Blocks the Confirm button until a value is present. | | **default** | `any` | optional | Value applied when the dialog opens. (An ActionParam spells this `defaultValue`.) | -| **options** | `{ label: string; value: string \| number \| boolean }[]` | optional | Static options for select-style widgets. | +| **options** | `Record[]` | 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. | | **object** | `string` | optional | Target object for a `lookup` widget. (An ActionParam spells this `reference`.) | | **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. | diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts index fa5c3e1a9e..06c1c247e2 100644 --- a/packages/spec/src/ui/bulk-action.zod.ts +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -125,7 +125,7 @@ export const BulkActionParamSchema = lazySchema(() => z.object({ options: z.array(z.object({ label: z.string().describe('Option label (plain string — not i18n-resolved on this path).'), value: z.union([z.string(), z.number(), z.boolean()]).describe('Stored value.'), - }).passthrough()).optional().describe('Static options for select-style widgets.'), + }).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.'), object: SnakeCaseIdentifierSchema.optional().describe("Target object for a `lookup` widget. (An ActionParam spells this `reference`.)"), 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.'),