Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/bulk-action-option-passthrough.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/references/ui/bulk-action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<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. |
| **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. |
Expand Down
20 changes: 15 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
|---|---|---|---|---|
Expand All @@ -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 |
Expand Down
46 changes: 46 additions & 0 deletions packages/spec/src/ui/bulk-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
18 changes: 17 additions & 1 deletion packages/spec/src/ui/bulk-action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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. 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.'),
Expand Down
Loading