From 2aa26de2188b333daa97766c184f40e79f769da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:06:21 +0000 Subject: [PATCH 1/6] feat(spec): name the derive path where a form author meets the option-value bound The form field's `options` describe states the rule for a metadata-form row whose key is a spec enum: omit `options`, the control derives the members from the served JSON Schema, and their meanings go in `helpText`. `defineForm`'s module-load refusal of an unspellable inline option value keeps the system-identifier grammar message and appends that remedy. The value bound and every schema shape are unchanged. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude --- .../src/ui/form-option-enum-derive.test.ts | 207 ++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 83 ++++++- 2 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/ui/form-option-enum-derive.test.ts diff --git a/packages/spec/src/ui/form-option-enum-derive.test.ts b/packages/spec/src/ui/form-option-enum-derive.test.ts new file mode 100644 index 0000000000..3c1eb4f233 --- /dev/null +++ b/packages/spec/src/ui/form-option-enum-derive.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #19678 — enum members come from the schema, never hand-listed (ruling + * 不动 + 声明, 2026-09-23). + * + * A form option `value` is a system identifier: `SelectOptionSchema.value`, + * which `FormSelectOptionSchema` reuses by reference. So an enum member that + * carries a hyphen or a capital — `object.managedBy`'s `system-data`, + * `action.openIn`'s `new-tab`, `action.execution`'s `perRecord` — cannot be + * written as an inline option at all, and `defineForm` throws at module load + * when an author tries. The ruling keeps that bound and declares the answer: + * on a metadata form, a row whose key is a spec enum omits `options`, the + * control derives the members from the served JSON Schema, and the meanings go + * in `helpText`. + * + * What this file pins is where an author meets that rule: + * + * 1. **The wall says what to do.** `defineForm`'s refusal of an unspellable + * value still carries the grammar message, and now names the derive path as + * its remedy. The firing control reads TODAY's message live — the object + * face raises the same grammar issue through the same property schema, with + * no remedy — and proves the predicate this file asserts with is red on it. + * 2. **The verdict did not move.** The same values are refused and the same + * values accepted as before; only a message grew. + * 3. **The remedy is scoped.** It rides a grammar refusal of an inline option + * `value` and nothing else a form parse can raise. + * 4. **The describe states the rule** on the served JSON Schema, where the + * metadata-admin renderer and an AI author read it. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { SelectOptionSchema } from '../data/field.zod'; +import { defineForm, FormFieldSchema, FormSelectOptionSchema } from './view.zod'; + +type Issue = { code: string; path: PropertyKey[]; message: string; errors?: Issue[][] }; + +/** Every leaf issue, with the full path (a field row is a union, so its issues nest). */ +function leaves(issues: readonly Issue[], at: readonly PropertyKey[] = []): Array { + return issues.flatMap((issue) => { + const fullPath = [...at, ...issue.path]; + return issue.code === 'invalid_union' && issue.errors + ? issue.errors.flatMap((branch) => leaves(branch, fullPath)) + : [{ ...issue, fullPath }]; + }); +} + +/** + * The ONE predicate this file judges a message by: does it name the derive + * path? It asserts the named subjects — omitting `options`, the served JSON + * Schema the members come from, `helpText` for their meanings — never the + * sentence around them. + */ +function namesDerivePath(message: string): boolean { + return /\bomit/i.test(message) + && message.includes('`options`') + && message.includes('JSON Schema') + && message.includes('`helpText`'); +} + +/** Build a one-row metadata form whose row lists a single inline option. */ +function formWithOption(value: string, extra: Record = {}) { + return () => defineForm({ + schemaId: 'action', + type: 'simple', + sections: [{ label: 'Behavior', fields: [{ field: 'openIn', options: [{ label: 'Option', value, ...extra }] }] }], + }); +} + +/** The issue `defineForm` raised at the row's option value, or a loud failure. */ +function optionValueIssue(build: () => unknown) { + let thrown: unknown; + try { + build(); + } catch (e) { + thrown = e; + } + expect(thrown, 'expected defineForm to REFUSE at module load').toBeInstanceOf(z.ZodError); + const hit = leaves((thrown as z.ZodError).issues as unknown as Issue[]) + .find((i) => i.fullPath.join('.') === 'sections.0.fields.0.options.0.value'); + expect(hit, `no issue at the option value in ${String((thrown as Error).message)}`).toBeDefined(); + return hit!; +} + +/** Today's message for the same value, read live off the object face — the same property schema. */ +function objectFaceMessage(value: string): string { + const r = SelectOptionSchema.safeParse({ label: 'Option', value }); + expect(r.success).toBe(false); + const hit = (r.error!.issues as unknown as Issue[]).find((i) => i.path.join('.') === 'value'); + expect(hit, 'the object face raised no issue at `value`').toBeDefined(); + return hit!.message; +} + +// The card's own three members, plus the two-character floor. +const UNSPELLABLE = [ + { value: 'new-tab', code: 'invalid_format', why: 'a hyphen (action.openIn)' }, + { value: 'perRecord', code: 'invalid_format', why: 'a capital (action.execution)' }, + { value: 'system-data', code: 'invalid_format', why: 'a hyphen (object.managedBy)' }, + { value: 'x', code: 'too_small', why: 'a single character' }, +] as const; + +describe('defineForm: the refusal of an unspellable option value names the derive path', () => { + it.each(UNSPELLABLE)('`$value` ($why) is refused with the remedy', ({ value, code }) => { + const issue = optionValueIssue(formWithOption(value)); + expect(issue.code).toBe(code); + expect(namesDerivePath(issue.message), issue.message).toBe(true); + }); + + it.each(UNSPELLABLE)('`$value`: the grammar message is kept, and the remedy follows it', ({ value }) => { + const today = objectFaceMessage(value); + const issue = optionValueIssue(formWithOption(value)); + expect(issue.message.startsWith(`${today}. `), issue.message).toBe(true); + }); + + it('firing control — the predicate is RED on today\'s message', () => { + // The object face raises the grammar issue through the very property schema + // the form face shares, and carries no remedy: this is the message + // `defineForm` threw before the ruling was executed. + for (const { value } of UNSPELLABLE) { + const today = objectFaceMessage(value); + expect(namesDerivePath(today), today).toBe(false); + } + }); + + it('a nested row (composite `fields`) gets the same remedy', () => { + let thrown: unknown; + try { + defineForm({ + schemaId: 'action', + type: 'simple', + sections: [{ label: 'Behavior', fields: [{ field: 'outer', fields: [{ field: 'inner', options: [{ label: 'New tab', value: 'new-tab' }] }] }] }], + }); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(z.ZodError); + const hit = leaves((thrown as z.ZodError).issues as unknown as Issue[]) + .find((i) => i.fullPath.join('.') === 'sections.0.fields.0.fields.0.options.0.value'); + expect(hit).toBeDefined(); + expect(namesDerivePath(hit!.message), hit!.message).toBe(true); + }); +}); + +describe('the verdict did not move — the bound stays (ruling item 1)', () => { + it('the form face refuses exactly what it refused before', () => { + for (const { value } of UNSPELLABLE) { + expect(FormSelectOptionSchema.safeParse({ label: 'Option', value }).success, value).toBe(false); + } + expect(FormSelectOptionSchema.safeParse({ label: 'New tab', value: 'new_tab' }).success).toBe(true); + }); + + it('a spellable inline option still builds', () => { + const form = defineForm({ + schemaId: 'action', + type: 'simple', + sections: [{ label: 'Behavior', fields: [{ field: 'mode', options: [{ label: 'Custom', value: 'custom' }] }] }], + }); + expect(form.data).toEqual({ provider: 'schema', schemaId: 'action' }); + }); +}); + +describe('the remedy is scoped to a grammar refusal of an inline option value', () => { + it('an unknown key on the option is answered without it', () => { + let thrown: unknown; + try { + formWithOption('new_tab', { colour: 'red' })(); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(z.ZodError); + const all = leaves((thrown as z.ZodError).issues as unknown as Issue[]); + expect(all.some((i) => i.code === 'unrecognized_keys')).toBe(true); + for (const issue of all) expect(namesDerivePath(issue.message), issue.message).toBe(false); + }); + + it('an unrelated refusal on the same form is answered without it', () => { + let thrown: unknown; + try { + defineForm({ schemaId: 'action', type: 'no_such_layout' as never, sections: [] }); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(z.ZodError); + for (const issue of leaves((thrown as z.ZodError).issues as unknown as Issue[])) { + expect(namesDerivePath(issue.message), issue.message).toBe(false); + } + }); +}); + +describe('the form field\'s `options` describe states the rule (ruling item 2)', () => { + const served = z.toJSONSchema(FormFieldSchema, { io: 'input', unrepresentable: 'any' }) as { + properties?: { options?: { description?: string } }; + }; + const text = served.properties?.options?.description ?? ''; + + it('names the derive path for a spec-enum row on a metadata form', () => { + expect(text).toContain('spec enum'); + expect(text).toContain('`defineForm`'); + expect(namesDerivePath(text), text).toBe(true); + }); + + it('keeps the per-option `default` prescription it already carried', () => { + expect(text).toContain('per-option `default`'); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 2d700dbca5..998be83994 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3231,8 +3231,21 @@ const FormFieldBaseSchema = lazySchema(() => { * [#12868] `FormSelectOptionSchema`, not `SelectOptionSchema`: the form-view * face refuses the per-option `default` key the object-field face enforces — * see the narrowed schema's docblock for the ruling and the census. + * + * [#19678] Enum members come from the schema, never hand-listed (ruling + * 不动 + 声明, 2026-09-23). An option `value` keeps the system-identifier + * bound it shares by reference with the object-field face, so an enum member + * carrying a hyphen or a capital (`system-data`, `new-tab`, `perRecord`) + * cannot be written as one at all. On a metadata form — schema-bound, + * built by {@link defineForm} — a row whose key is a spec enum therefore + * omits `options`: the control derives the members from the served JSON + * Schema, and their meanings go in `helpText` (the `object.managedBy` / + * `action.openIn` / `action.execution` rows are the reference shape). The + * describe below states the rule where an author meets it, and + * `defineForm`'s module-load refusal of an unspellable value names the same + * path as its remedy. */ - options: z.array(FormSelectOptionSchema).optional().describe('Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition)'), + options: z.array(FormSelectOptionSchema).optional().describe('Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), a row whose key is a spec enum omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. An option `value` is a lowercase system identifier, so an enum member carrying a hyphen or a capital cannot be listed here at all.'), /** Reference object for lookup/master_detail fields */ reference: z.string().optional().describe('Target object name for lookup/master_detail fields'), @@ -6269,6 +6282,11 @@ export function expandViewContainer(object: string, container: any): ExpandedVie * and pulls field metadata from the resolved JSON Schema instead of from * ObjectQL. * + * A row whose key is a spec enum omits `options` — the control derives the + * members from that JSON Schema, and their meanings go in `helpText`. An + * inline option `value` that fails the system-identifier grammar is refused + * here, at module load, and the refusal names that path as its remedy. + * * @example * ```ts * export const reportForm = defineForm({ @@ -6294,10 +6312,71 @@ export function defineForm( config: Omit, 'data'> & { schemaId: string }, ): FormViewParsed { const { schemaId, ...rest } = config; - return FormViewSchema.parse({ + const parsed = FormViewSchema.safeParse({ ...rest, data: { provider: 'schema', schemaId }, }); + if (parsed.success) return parsed.data; + throw new z.ZodError(withOptionValueDeriveRemedy(parsed.error.issues)); +} + +/** + * [#19678] The remedy {@link defineForm}'s refusal of an unspellable inline + * option `value` carries — ruling 不动 + 声明 (2026-09-23): the bound stays, + * and the wall says what to do. + * + * The refusal itself is `SystemIdentifierSchema`'s grammar message + * (`shared/identifiers.zod.ts`), reached through `SelectOptionSchema.value`, + * which the form face reuses BY REFERENCE (pinned in + * `form-select-option.test.ts`). That message cannot carry this remedy where it + * is declared: the same grammar bounds object-field options and three + * object-storage names, and "derive the members from the served JSON Schema" + * is true only on a schema-bound form. `defineForm` is exactly that door — it + * stamps `data.provider: 'schema'` on every form it builds — so the remedy is + * appended here, to the issue that door raises, and nowhere else. + */ +const FORM_OPTION_VALUE_DERIVE_REMEDY = + 'An enum member carrying a hyphen, a capital or a single character cannot be a form option ' + + '`value`, which is a lowercase system identifier. When this row edits a spec enum, omit ' + + '`options`: the control derives the members from the served JSON Schema, and their ' + + 'meanings go in `helpText`.'; + +/** + * The two issue codes the system-identifier grammar raises on a string: the + * pattern (`invalid_format`) and the two-character floor (`too_small`). + */ +const OPTION_VALUE_GRAMMAR_CODES: ReadonlySet = new Set(['invalid_format', 'too_small']); + +/** `…options..value` — an inline option's `value` on a form field row. */ +function isInlineOptionValuePath(path: readonly PropertyKey[]): boolean { + const n = path.length; + return n >= 3 && path[n - 1] === 'value' && typeof path[n - 2] === 'number' && path[n - 3] === 'options'; +} + +/** + * [#19678] Append {@link FORM_OPTION_VALUE_DERIVE_REMEDY} to every grammar + * refusal of an inline option `value` in a failed `FormViewSchema` parse. + * + * A field row is a union (bare field name | row object), so the option's issue + * usually sits inside an `invalid_union` issue's `errors`, with a path relative + * to the union's — the walk carries the prefix down so the full path is judged. + * Nothing is added, removed or re-coded: the verdict and the issue list are the + * parse's own, and only the matching messages grow the remedy sentence. + */ +function withOptionValueDeriveRemedy( + issues: readonly z.core.$ZodIssue[], + at: readonly PropertyKey[] = [], +): z.core.$ZodIssue[] { + return issues.map((issue) => { + const path = [...at, ...issue.path]; + if (issue.code === 'invalid_union') { + return { ...issue, errors: issue.errors.map((branch) => withOptionValueDeriveRemedy(branch, path)) }; + } + if (OPTION_VALUE_GRAMMAR_CODES.has(issue.code) && isInlineOptionValuePath(path)) { + return { ...issue, message: `${issue.message}. ${FORM_OPTION_VALUE_DERIVE_REMEDY}` }; + } + return issue; + }); } export type View = z.input; From 67c8ac5a79d5fc682341ede98ce00c11021a862b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:23:38 +0000 Subject: [PATCH 2/6] fix(spec): keep the invalid_union issue type through the remedy walk; add changeset Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude --- .changeset/19678-form-option-enum-derive-remedy.md | 13 +++++++++++++ packages/spec/src/ui/view.zod.ts | 10 ++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 .changeset/19678-form-option-enum-derive-remedy.md diff --git a/.changeset/19678-form-option-enum-derive-remedy.md b/.changeset/19678-form-option-enum-derive-remedy.md new file mode 100644 index 0000000000..ab48bc7039 --- /dev/null +++ b/.changeset/19678-form-option-enum-derive-remedy.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": patch +--- + +A metadata form's option-value refusal now says what to do: `defineForm`'s module-load refusal of an inline option `value` that fails the system-identifier grammar names the derive path, and the form field's `options` describe states the same rule (#19678). + +Clause-②: no + +A form option `value` is a lowercase system identifier — `FormSelectOptionSchema` reuses `SelectOptionSchema.value` by reference — so an enum member carrying a hyphen or a capital (`object.managedBy`'s `system-data`, `action.openIn`'s `new-tab`, `action.execution`'s `perRecord`) cannot be written as an inline option at all. That bound stays. For a metadata-form row whose key is a spec enum, the answer is to omit `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. + +- **The refusal names the remedy.** `defineForm` still throws a `ZodError` at module load with the same issues and codes (`invalid_format` for the pattern, `too_small` for the two-character floor). The grammar message on an inline option's `value` is kept and now carries the derive path after it. Only schema-bound forms built by `defineForm` get this sentence. The grammar message where it is declared (`SystemIdentifierSchema`) is unchanged, because it also bounds object-field options and three object-storage names, where omitting `options` is not the answer. +- **The describe states the rule** on `FormFieldSchema.options`, and that text is served in the JSON Schema and the generated reference page. +- ⛔ **No accept-set change.** Every value refused before is still refused, and every value accepted before is still accepted. No key, export or schema shape moves. diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 998be83994..ecb63c3fa7 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -6369,11 +6369,17 @@ function withOptionValueDeriveRemedy( ): z.core.$ZodIssue[] { return issues.map((issue) => { const path = [...at, ...issue.path]; + // Spread copies keep every field the parse raised; the casts restore the + // discriminated union the spread widens (`errors` on the no-match and + // multiple-match variants of `invalid_union` are typed apart). if (issue.code === 'invalid_union') { - return { ...issue, errors: issue.errors.map((branch) => withOptionValueDeriveRemedy(branch, path)) }; + return { + ...issue, + errors: issue.errors.map((branch) => withOptionValueDeriveRemedy(branch, path)), + } as z.core.$ZodIssue; } if (OPTION_VALUE_GRAMMAR_CODES.has(issue.code) && isInlineOptionValuePath(path)) { - return { ...issue, message: `${issue.message}. ${FORM_OPTION_VALUE_DERIVE_REMEDY}` }; + return { ...issue, message: `${issue.message}. ${FORM_OPTION_VALUE_DERIVE_REMEDY}` } as z.core.$ZodIssue; } return issue; }); From ebd7fc2fa84030ffac014e61262f72a364f56596 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:38:38 +0000 Subject: [PATCH 3/6] docs(spec): regenerate the ui/view reference for the options describe Generator output of `check:generated --fix` (check:docs was the one stale artifact); not hand-edited. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude --- content/docs/references/ui/view.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index bb5d974e18..96e48184f2 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -181,7 +181,7 @@ Column footer summary configuration | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name (snake_case) | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | optional | Field type (auto-infers widget if omitted) | -| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition) | +| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), a row whose key is a spec enum omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. An option `value` is a lowercase system identifier, so an enum member carrying a hyphen or a capital cannot be listed here at all. | | **reference** | `string` | optional | Target object name for lookup/master_detail fields | | **publicPicker** | `{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }` | optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. | | **maxLength** | `integer` | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) | @@ -346,7 +346,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name (snake_case) | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | Field type (auto-infers widget if omitted) | -| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition) | +| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), a row whose key is a spec enum omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. An option `value` is a lowercase system identifier, so an enum member carrying a hyphen or a capital cannot be listed here at all. | | **reference** | `string` | optional | Target object name for lookup/master_detail fields | | **publicPicker** | `{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }` | optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. | | **maxLength** | `integer` | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) | From 182ed4c154fa6b08e3ad4293fc3e475e9030cc07 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:16:37 +0000 Subject: [PATCH 4/6] fix(spec): narrow the options describe and the form refusal remedy to members that cannot be spelled An enum-typed metadata-form row may carry an inline options list, for human labels or a deliberate subset. Only a row whose members cannot be spelled as option values omits options and derives them from the served JSON Schema, with meanings in helpText. The describe on FormFieldSchema.options and the remedy sentence defineForm appends to an option-value grammar refusal now state exactly that. What is refused and accepted is unchanged. Tests: each case carries a firing and a dark control on real spec enums (object.managedBy refused with options and green without; a labelled object.sharingModel list and the field.deleteBehavior subset green, and refused with one member mis-spelled). The old blanket-rule pins are reversed into assertions of the narrowed wording. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude --- .../19678-form-option-enum-derive-remedy.md | 8 +- .../src/ui/form-option-enum-derive.test.ts | 288 ++++++++++++++---- packages/spec/src/ui/view.zod.ts | 46 +-- 3 files changed, 263 insertions(+), 79 deletions(-) diff --git a/.changeset/19678-form-option-enum-derive-remedy.md b/.changeset/19678-form-option-enum-derive-remedy.md index ab48bc7039..ef4b7531fe 100644 --- a/.changeset/19678-form-option-enum-derive-remedy.md +++ b/.changeset/19678-form-option-enum-derive-remedy.md @@ -2,12 +2,12 @@ "@objectstack/spec": patch --- -A metadata form's option-value refusal now says what to do: `defineForm`'s module-load refusal of an inline option `value` that fails the system-identifier grammar names the derive path, and the form field's `options` describe states the same rule (#19678). +A metadata form's option-value refusal now says what to do: `defineForm`'s module-load refusal of an inline option `value` that fails the system-identifier grammar names the derive path, and the form field's `options` describe states the rule it belongs to (#19678, #19907). Clause-②: no -A form option `value` is a lowercase system identifier — `FormSelectOptionSchema` reuses `SelectOptionSchema.value` by reference — so an enum member carrying a hyphen or a capital (`object.managedBy`'s `system-data`, `action.openIn`'s `new-tab`, `action.execution`'s `perRecord`) cannot be written as an inline option at all. That bound stays. For a metadata-form row whose key is a spec enum, the answer is to omit `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. +A form option `value` is a lowercase system identifier — `FormSelectOptionSchema` reuses `SelectOptionSchema.value` by reference — so an enum member carrying a hyphen or a capital (`object.managedBy`'s `system-data`, `action.openIn`'s `new-tab`, `action.execution`'s `perRecord`) cannot be written as an inline option at all. That bound stays. An enum-typed metadata-form row may still carry an inline `options` list, to give its members human labels or to offer a deliberate subset. A row whose members cannot be spelled as option values omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. -- **The refusal names the remedy.** `defineForm` still throws a `ZodError` at module load with the same issues and codes (`invalid_format` for the pattern, `too_small` for the two-character floor). The grammar message on an inline option's `value` is kept and now carries the derive path after it. Only schema-bound forms built by `defineForm` get this sentence. The grammar message where it is declared (`SystemIdentifierSchema`) is unchanged, because it also bounds object-field options and three object-storage names, where omitting `options` is not the answer. -- **The describe states the rule** on `FormFieldSchema.options`, and that text is served in the JSON Schema and the generated reference page. +- **The refusal names the remedy.** `defineForm` still throws a `ZodError` at module load with the same issues and codes (`invalid_format` for the pattern, `too_small` for the two-character floor). The grammar message on an inline option's `value` is kept, and now carries the derive path after it, for a row whose members cannot be spelled as option values. Only schema-bound forms built by `defineForm` get this sentence. The grammar message where it is declared (`SystemIdentifierSchema`) is unchanged, because it also bounds object-field options and three object-storage names, where omitting `options` is not the answer. +- **The describe states the rule** on `FormFieldSchema.options`: an inline list is allowed on an enum-typed row, and the derive path is named for a row whose members cannot be spelled. That text is served in the JSON Schema and on the generated reference page. - ⛔ **No accept-set change.** Every value refused before is still refused, and every value accepted before is still accepted. No key, export or schema shape moves. diff --git a/packages/spec/src/ui/form-option-enum-derive.test.ts b/packages/spec/src/ui/form-option-enum-derive.test.ts index 3c1eb4f233..f52f212cfe 100644 --- a/packages/spec/src/ui/form-option-enum-derive.test.ts +++ b/packages/spec/src/ui/form-option-enum-derive.test.ts @@ -1,38 +1,50 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #19678 — enum members come from the schema, never hand-listed (ruling - * 不动 + 声明, 2026-09-23). + * #19678 / #19907 — derive only where a member cannot be spelled (ruling 乙, + * record 5805845085, which narrows item 1 of ruling 不动 + 声明, record + * 5793380467). * * A form option `value` is a system identifier: `SelectOptionSchema.value`, * which `FormSelectOptionSchema` reuses by reference. So an enum member that * carries a hyphen or a capital — `object.managedBy`'s `system-data`, * `action.openIn`'s `new-tab`, `action.execution`'s `perRecord` — cannot be * written as an inline option at all, and `defineForm` throws at module load - * when an author tries. The ruling keeps that bound and declares the answer: - * on a metadata form, a row whose key is a spec enum omits `options`, the - * control derives the members from the served JSON Schema, and the meanings go - * in `helpText`. + * when an author tries. The bound stays. The rule, as ruling 乙 records it: an + * enum-typed metadata-form row MAY carry an inline `options` list (human + * labels, a deliberate subset); a row whose members cannot be spelled as + * option values OMITS `options`, the control derives the members from the + * served JSON Schema, and the meanings go in `helpText`. * * What this file pins is where an author meets that rule: * * 1. **The wall says what to do.** `defineForm`'s refusal of an unspellable - * value still carries the grammar message, and now names the derive path as - * its remedy. The firing control reads TODAY's message live — the object - * face raises the same grammar issue through the same property schema, with - * no remedy — and proves the predicate this file asserts with is red on it. - * 2. **The verdict did not move.** The same values are refused and the same + * value still carries the grammar message, and names the derive path — + * scoped to a row whose members cannot be spelled — as its remedy. The + * firing control reads TODAY's message live — the object face raises the + * same grammar issue through the same property schema, with no remedy — + * and proves the predicates this file asserts with are red on it. + * 2. **Each case has a firing and a dark control, on real spec enums.** An + * unspellable row with inline `options` is refused and the same row without + * them builds; a labelled spellable row and a deliberate subset build, and + * the same rows with one member mis-spelled are refused. Every enum is read + * off the served JSON Schema, so "spellable", "unspellable" and "subset" + * are measured here, never assumed. + * 3. **The verdict did not move.** The same values are refused and the same * values accepted as before; only a message grew. - * 3. **The remedy is scoped.** It rides a grammar refusal of an inline option + * 4. **The remedy is scoped.** It rides a grammar refusal of an inline option * `value` and nothing else a form parse can raise. - * 4. **The describe states the rule** on the served JSON Schema, where the - * metadata-admin renderer and an AI author read it. + * 5. **The describe states the rule** on the served JSON Schema, where the + * metadata-admin renderer and an AI author read it — the permission for an + * inline list, and the derive path for an unspellable row, and ⛔ never + * again the blanket "a spec-enum row omits `options`" it first carried. */ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; import { SelectOptionSchema } from '../data/field.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; import { defineForm, FormFieldSchema, FormSelectOptionSchema } from './view.zod'; type Issue = { code: string; path: PropertyKey[]; message: string; errors?: Issue[][] }; @@ -48,10 +60,10 @@ function leaves(issues: readonly Issue[], at: readonly PropertyKey[] = []): Arra } /** - * The ONE predicate this file judges a message by: does it name the derive - * path? It asserts the named subjects — omitting `options`, the served JSON - * Schema the members come from, `helpText` for their meanings — never the - * sentence around them. + * The predicate this file judges a message by: does it name the derive path? + * It asserts the named subjects — omitting `options`, the served JSON Schema + * the members come from, `helpText` for their meanings — never the sentence + * around them. */ function namesDerivePath(message: string): boolean { return /\bomit/i.test(message) @@ -60,6 +72,24 @@ function namesDerivePath(message: string): boolean { && message.includes('`helpText`'); } +/** + * Ruling 乙's narrowing, as a predicate: the derive path is conditioned on + * members that CANNOT BE SPELLED as option values — not on every enum row. + */ +function scopesDeriveToUnspellable(message: string): boolean { + return /cannot be spelled as option values/.test(message); +} + +/** + * The blanket rule ruling 乙 narrowed away — "a spec-enum row omits + * `options`", in either spelling this PR first shipped (the describe's + * declarative one, the remedy's imperative one). + */ +function statesBlanketOmitRule(message: string): boolean { + return /whose key is a spec enum omits `options`/.test(message) + || /edits a spec enum, omit `options`/.test(message); +} + /** Build a one-row metadata form whose row lists a single inline option. */ function formWithOption(value: string, extra: Record = {}) { return () => defineForm({ @@ -69,8 +99,17 @@ function formWithOption(value: string, extra: Record = {}) { }); } -/** The issue `defineForm` raised at the row's option value, or a loud failure. */ -function optionValueIssue(build: () => unknown) { +/** Build a one-row metadata form over `schemaId` with the given row. */ +function formWithRow(schemaId: string, row: Record) { + return () => defineForm({ + schemaId, + type: 'simple', + sections: [{ label: 'Section', fields: [row as never] }], + }); +} + +/** The ZodError a build threw, or a loud failure when it built. */ +function refusal(build: () => unknown): z.ZodError { let thrown: unknown; try { build(); @@ -78,9 +117,15 @@ function optionValueIssue(build: () => unknown) { thrown = e; } expect(thrown, 'expected defineForm to REFUSE at module load').toBeInstanceOf(z.ZodError); - const hit = leaves((thrown as z.ZodError).issues as unknown as Issue[]) - .find((i) => i.fullPath.join('.') === 'sections.0.fields.0.options.0.value'); - expect(hit, `no issue at the option value in ${String((thrown as Error).message)}`).toBeDefined(); + return thrown as z.ZodError; +} + +/** The issue `defineForm` raised at the row's option value, or a loud failure. */ +function optionValueIssue(build: () => unknown, index = 0) { + const thrown = refusal(build); + const hit = leaves(thrown.issues as unknown as Issue[]) + .find((i) => i.fullPath.join('.') === `sections.0.fields.0.options.${index}.value`); + expect(hit, `no issue at the option value in ${thrown.message}`).toBeDefined(); return hit!; } @@ -93,6 +138,23 @@ function objectFaceMessage(value: string): string { return hit!.message; } +/** Can `value` be spelled as a form option value? Asked of the form face itself. */ +function spellable(value: string): boolean { + return FormSelectOptionSchema.safeParse({ label: 'Option', value }).success; +} + +/** The members `type.key` declares, read off the SERVED JSON Schema (input side). */ +function servedEnum(type: string, key: string): string[] { + const schema = getMetadataTypeSchema(type); + expect(schema, `no metadata type schema for '${type}'`).toBeDefined(); + const json = z.toJSONSchema(schema!, { io: 'input', unrepresentable: 'any' }) as { + properties?: Record; + }; + const members = json.properties?.[key]?.enum; + expect(Array.isArray(members) && members.length > 0, `'${type}.${key}' serves no enum`).toBe(true); + return members as string[]; +} + // The card's own three members, plus the two-character floor. const UNSPELLABLE = [ { value: 'new-tab', code: 'invalid_format', why: 'a hyphen (action.openIn)' }, @@ -108,42 +170,159 @@ describe('defineForm: the refusal of an unspellable option value names the deriv expect(namesDerivePath(issue.message), issue.message).toBe(true); }); + it.each(UNSPELLABLE)('`$value`: the remedy scopes the derive path to members that cannot be spelled (ruling 乙)', ({ value }) => { + const issue = optionValueIssue(formWithOption(value)); + expect(scopesDeriveToUnspellable(issue.message), issue.message).toBe(true); + expect(statesBlanketOmitRule(issue.message), issue.message).toBe(false); + }); + it.each(UNSPELLABLE)('`$value`: the grammar message is kept, and the remedy follows it', ({ value }) => { const today = objectFaceMessage(value); const issue = optionValueIssue(formWithOption(value)); expect(issue.message.startsWith(`${today}. `), issue.message).toBe(true); }); - it('firing control — the predicate is RED on today\'s message', () => { + it('firing control — both predicates are RED on today\'s message', () => { // The object face raises the grammar issue through the very property schema // the form face shares, and carries no remedy: this is the message // `defineForm` threw before the ruling was executed. for (const { value } of UNSPELLABLE) { const today = objectFaceMessage(value); expect(namesDerivePath(today), today).toBe(false); + expect(scopesDeriveToUnspellable(today), today).toBe(false); } }); + it('firing control — the blanket-rule predicate is LIT on the wording ruling 乙 narrowed away', () => { + // The two spellings this PR first shipped, held as fixtures ONLY to prove + // `statesBlanketOmitRule` can fire — a predicate that is never true would + // make every "states no blanket rule" assertion in this file vacuous. + expect(statesBlanketOmitRule('On a metadata form (schema-bound, built by `defineForm`), a row whose key is a spec enum omits `options`: …')).toBe(true); + expect(statesBlanketOmitRule('When this row edits a spec enum, omit `options`: …')).toBe(true); + }); + it('a nested row (composite `fields`) gets the same remedy', () => { - let thrown: unknown; - try { - defineForm({ - schemaId: 'action', - type: 'simple', - sections: [{ label: 'Behavior', fields: [{ field: 'outer', fields: [{ field: 'inner', options: [{ label: 'New tab', value: 'new-tab' }] }] }] }], - }); - } catch (e) { - thrown = e; - } - expect(thrown).toBeInstanceOf(z.ZodError); - const hit = leaves((thrown as z.ZodError).issues as unknown as Issue[]) + const thrown = refusal(() => defineForm({ + schemaId: 'action', + type: 'simple', + sections: [{ label: 'Behavior', fields: [{ field: 'outer', fields: [{ field: 'inner', options: [{ label: 'New tab', value: 'new-tab' }] }] }] }], + })); + const hit = leaves(thrown.issues as unknown as Issue[]) .find((i) => i.fullPath.join('.') === 'sections.0.fields.0.fields.0.options.0.value'); expect(hit).toBeDefined(); expect(namesDerivePath(hit!.message), hit!.message).toBe(true); + expect(scopesDeriveToUnspellable(hit!.message), hit!.message).toBe(true); }); }); -describe('the verdict did not move — the bound stays (ruling item 1)', () => { +describe('ruling 乙 item 1, on real spec enums — each case with a firing and a dark control', () => { + describe('a row whose members cannot be spelled (`object.managedBy`)', () => { + const members = servedEnum('object', 'managedBy'); + + it('lit precondition: the served enum carries members the form face cannot spell', () => { + expect(members.filter((m) => !spellable(m)).length).toBeGreaterThan(0); + expect(members.filter((m) => !spellable(m))).toContain('system-data'); + }); + + it('FIRING — carrying inline `options`, it is REFUSED, and the remedy names the derive path', () => { + const thrown = refusal(formWithRow('object', { + field: 'managedBy', + type: 'select', + options: members.map((m) => ({ label: m, value: m })), + })); + const byPath = new Map(leaves(thrown.issues as unknown as Issue[]).map((i) => [i.fullPath.join('.'), i])); + members.forEach((m, index) => { + const issue = byPath.get(`sections.0.fields.0.options.${index}.value`); + if (spellable(m)) { + expect(issue, `spellable member '${m}' was refused`).toBeUndefined(); + } else { + expect(issue, `unspellable member '${m}' was not refused`).toBeDefined(); + expect(issue!.code).toBe('invalid_format'); + expect(namesDerivePath(issue!.message), issue!.message).toBe(true); + expect(scopesDeriveToUnspellable(issue!.message), issue!.message).toBe(true); + } + }); + }); + + it('DARK — the same row without `options`, meanings in `helpText`, is GREEN', () => { + const form = formWithRow('object', { + field: 'managedBy', + helpText: `Lifecycle bucket: ${members.join(', ')}.`, + })(); + const row = form.sections![0]!.fields![0] as { field: string; options?: unknown }; + expect(row.field).toBe('managedBy'); + expect(row.options).toBeUndefined(); + }); + }); + + describe('a spellable enum row with a labelled inline list (`object.sharingModel`, the #19331 shape)', () => { + const members = servedEnum('object', 'sharingModel'); + const LABELS: Record = { + private: 'Private — owner only', + public_read: 'Public read — everyone reads, owner writes', + public_read_write: 'Public read/write — everyone reads and writes', + controlled_by_parent: 'Controlled by parent — derived from the master record', + }; + const labelled = () => members.map((m) => ({ label: LABELS[m] ?? m, value: m })); + + it('lit precondition: every served member is spellable, and each carries a human label', () => { + for (const m of members) expect(spellable(m), m).toBe(true); + for (const m of members) expect(LABELS[m], `no human label for '${m}'`).toBeDefined(); + }); + + it('DARK — the labelled full list is GREEN, labels kept', () => { + const form = formWithRow('object', { field: 'sharingModel', type: 'select', options: labelled() })(); + const row = form.sections![0]!.fields![0] as { options?: Array<{ label: string; value: string }> }; + expect(row.options).toEqual(labelled()); + }); + + it('FIRING — the same list with one member re-spelled with a hyphen is REFUSED at that member', () => { + const options = labelled().map((o) => (o.value === 'public_read' ? { ...o, value: 'public-read' } : o)); + const index = options.findIndex((o) => o.value === 'public-read'); + expect(index).toBeGreaterThanOrEqual(0); + const issue = optionValueIssue(formWithRow('object', { field: 'sharingModel', type: 'select', options }), index); + expect(issue.code).toBe('invalid_format'); + }); + }); + + describe('a deliberate subset (`field.deleteBehavior` on a master_detail row, no `set_null`)', () => { + const members = servedEnum('field', 'deleteBehavior'); + const SUBSET = [ + { label: 'Cascade (delete children)', value: 'cascade' }, + { label: 'Restrict (block the delete)', value: 'restrict' }, + ]; + + it('lit precondition: the list is a PROPER subset of the served enum', () => { + for (const { value } of SUBSET) expect(members, value).toContain(value); + expect(members).toContain('set_null'); + expect(SUBSET.map((o) => o.value)).not.toContain('set_null'); + }); + + it('DARK — the subset is GREEN, and is not widened to the enum', () => { + const form = formWithRow('field', { + field: 'deleteBehavior', + type: 'select', + visibleWhen: "data.type == 'master_detail'", + options: SUBSET, + })(); + const row = form.sections![0]!.fields![0] as { options?: Array<{ value: string }> }; + expect(row.options!.map((o) => o.value)).toEqual(['cascade', 'restrict']); + }); + + it('FIRING — the same subset with one member re-spelled with a capital is REFUSED at that member', () => { + const options = SUBSET.map((o) => (o.value === 'cascade' ? { ...o, value: 'Cascade' } : o)); + const issue = optionValueIssue(formWithRow('field', { + field: 'deleteBehavior', + type: 'select', + visibleWhen: "data.type == 'master_detail'", + options, + }), 0); + expect(issue.code).toBe('invalid_format'); + }); + }); +}); + +describe('the verdict did not move — the bound stays', () => { it('the form face refuses exactly what it refused before', () => { for (const { value } of UNSPELLABLE) { expect(FormSelectOptionSchema.safeParse({ label: 'Option', value }).success, value).toBe(false); @@ -163,42 +342,39 @@ describe('the verdict did not move — the bound stays (ruling item 1)', () => { describe('the remedy is scoped to a grammar refusal of an inline option value', () => { it('an unknown key on the option is answered without it', () => { - let thrown: unknown; - try { - formWithOption('new_tab', { colour: 'red' })(); - } catch (e) { - thrown = e; - } - expect(thrown).toBeInstanceOf(z.ZodError); - const all = leaves((thrown as z.ZodError).issues as unknown as Issue[]); + const all = leaves(refusal(formWithOption('new_tab', { colour: 'red' })).issues as unknown as Issue[]); expect(all.some((i) => i.code === 'unrecognized_keys')).toBe(true); for (const issue of all) expect(namesDerivePath(issue.message), issue.message).toBe(false); }); it('an unrelated refusal on the same form is answered without it', () => { - let thrown: unknown; - try { - defineForm({ schemaId: 'action', type: 'no_such_layout' as never, sections: [] }); - } catch (e) { - thrown = e; - } - expect(thrown).toBeInstanceOf(z.ZodError); - for (const issue of leaves((thrown as z.ZodError).issues as unknown as Issue[])) { + const thrown = refusal(() => defineForm({ schemaId: 'action', type: 'no_such_layout' as never, sections: [] })); + for (const issue of leaves(thrown.issues as unknown as Issue[])) { expect(namesDerivePath(issue.message), issue.message).toBe(false); } }); }); -describe('the form field\'s `options` describe states the rule (ruling item 2)', () => { +describe('the form field\'s `options` describe states ruling 乙\'s rule', () => { const served = z.toJSONSchema(FormFieldSchema, { io: 'input', unrepresentable: 'any' }) as { properties?: { options?: { description?: string } }; }; const text = served.properties?.options?.description ?? ''; - it('names the derive path for a spec-enum row on a metadata form', () => { - expect(text).toContain('spec enum'); + it('permits an inline list on an enum-typed metadata-form row, for human labels or a deliberate subset', () => { expect(text).toContain('`defineForm`'); + expect(text).toMatch(/enum-typed row may list its members/); + expect(text).toContain('human labels'); + expect(text).toContain('deliberate subset'); + }); + + it('names the derive path, scoped to a row whose members cannot be spelled', () => { expect(namesDerivePath(text), text).toBe(true); + expect(scopesDeriveToUnspellable(text), text).toBe(true); + }); + + it('no longer states the blanket "a spec-enum row omits `options`" rule ruling 乙 narrowed away', () => { + expect(statesBlanketOmitRule(text), text).toBe(false); }); it('keeps the per-option `default` prescription it already carried', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 79de0243b7..d6471f538d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3278,20 +3278,24 @@ const FormFieldBaseSchema = lazySchema(() => { * face refuses the per-option `default` key the object-field face enforces — * see the narrowed schema's docblock for the ruling and the census. * - * [#19678] Enum members come from the schema, never hand-listed (ruling - * 不动 + 声明, 2026-09-23). An option `value` keeps the system-identifier - * bound it shares by reference with the object-field face, so an enum member - * carrying a hyphen or a capital (`system-data`, `new-tab`, `perRecord`) - * cannot be written as one at all. On a metadata form — schema-bound, - * built by {@link defineForm} — a row whose key is a spec enum therefore - * omits `options`: the control derives the members from the served JSON + * [#19678] Derive only where a member cannot be spelled (ruling 乙 on + * #19907, record 5805845085, which narrows item 1 of ruling 不动 + 声明, + * record 5793380467). An option `value` keeps the system-identifier bound it + * shares by reference with the object-field face, so an enum member carrying + * a hyphen or a capital (`system-data`, `new-tab`, `perRecord`) cannot be + * written as one at all. On a metadata form — schema-bound, built by + * {@link defineForm} — an enum-typed row MAY carry an inline `options` list, + * for human labels or a deliberate subset (the #19331 `object.ownership` / + * `sharingModel` rows and the master_detail `deleteBehavior` rows are the + * reference shapes). A row whose members cannot be spelled as option values + * OMITS `options`: the control derives the members from the served JSON * Schema, and their meanings go in `helpText` (the `object.managedBy` / * `action.openIn` / `action.execution` rows are the reference shape). The * describe below states the rule where an author meets it, and - * `defineForm`'s module-load refusal of an unspellable value names the same - * path as its remedy. + * `defineForm`'s module-load refusal of an unspellable value names the + * derive path as its remedy. */ - options: z.array(FormSelectOptionSchema).optional().describe('Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), a row whose key is a spec enum omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. An option `value` is a lowercase system identifier, so an enum member carrying a hyphen or a capital cannot be listed here at all.'), + options: z.array(FormSelectOptionSchema).optional().describe('Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), an enum-typed row may list its members here, to give them human labels or to offer a deliberate subset. An option `value` is a lowercase system identifier, so a row whose members cannot be spelled as option values (a hyphen, a capital) omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`.'), /** Reference object for lookup/master_detail fields */ reference: z.string().optional().describe('Target object name for lookup/master_detail fields'), @@ -6328,10 +6332,12 @@ export function expandViewContainer(object: string, container: any): ExpandedVie * and pulls field metadata from the resolved JSON Schema instead of from * ObjectQL. * - * A row whose key is a spec enum omits `options` — the control derives the - * members from that JSON Schema, and their meanings go in `helpText`. An - * inline option `value` that fails the system-identifier grammar is refused - * here, at module load, and the refusal names that path as its remedy. + * An enum-typed row may carry an inline `options` list, for human labels or a + * deliberate subset. A row whose members cannot be spelled as option values + * omits `options` — the control derives the members from that JSON Schema, + * and their meanings go in `helpText`. An inline option `value` that fails + * the system-identifier grammar is refused here, at module load, and the + * refusal names that path as its remedy. * * @example * ```ts @@ -6368,8 +6374,10 @@ export function defineForm( /** * [#19678] The remedy {@link defineForm}'s refusal of an unspellable inline - * option `value` carries — ruling 不动 + 声明 (2026-09-23): the bound stays, - * and the wall says what to do. + * option `value` carries — ruling 乙 (record 5805845085, narrowing ruling + * 不动 + 声明): the bound stays, an enum-typed row may still list spellable + * members inline, and the wall names the derive path for a row whose members + * cannot be spelled. * * The refusal itself is `SystemIdentifierSchema`'s grammar message * (`shared/identifiers.zod.ts`), reached through `SelectOptionSchema.value`, @@ -6383,9 +6391,9 @@ export function defineForm( */ const FORM_OPTION_VALUE_DERIVE_REMEDY = 'An enum member carrying a hyphen, a capital or a single character cannot be a form option ' - + '`value`, which is a lowercase system identifier. When this row edits a spec enum, omit ' - + '`options`: the control derives the members from the served JSON Schema, and their ' - + 'meanings go in `helpText`.'; + + '`value`, which is a lowercase system identifier. When this row edits a spec enum whose ' + + 'members cannot be spelled as option values, omit `options`: the control derives the ' + + 'members from the served JSON Schema, and their meanings go in `helpText`.'; /** * The two issue codes the system-identifier grammar raises on a string: the From 74ea5dbba3b964bce1a5825ffeeb36958b0144f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:22:09 +0000 Subject: [PATCH 5/6] docs(spec): regenerate the ui/view reference for the narrowed options describe Generator output only (pnpm --filter @objectstack/spec gen:docs after a spec build): the two FormField options rows. Against the merged main tip the page differs in exactly those two rows. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude --- content/docs/references/ui/view.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 96e48184f2..347f024d3e 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -181,7 +181,7 @@ Column footer summary configuration | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name (snake_case) | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | optional | Field type (auto-infers widget if omitted) | -| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), a row whose key is a spec enum omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. An option `value` is a lowercase system identifier, so an enum member carrying a hyphen or a capital cannot be listed here at all. | +| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), an enum-typed row may list its members here, to give them human labels or to offer a deliberate subset. An option `value` is a lowercase system identifier, so a row whose members cannot be spelled as option values (a hyphen, a capital) omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. | | **reference** | `string` | optional | Target object name for lookup/master_detail fields | | **publicPicker** | `{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }` | optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. | | **maxLength** | `integer` | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) | @@ -346,7 +346,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name (snake_case) | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | Field type (auto-infers widget if omitted) | -| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), a row whose key is a spec enum omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. An option `value` is a lowercase system identifier, so an enum member carrying a hyphen or a capital cannot be listed here at all. | +| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), an enum-typed row may list its members here, to give them human labels or to offer a deliberate subset. An option `value` is a lowercase system identifier, so a row whose members cannot be spelled as option values (a hyphen, a capital) omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. | | **reference** | `string` | optional | Target object name for lookup/master_detail fields | | **publicPicker** | `{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }` | optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. | | **maxLength** | `integer` | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) | From 76a053e9d0f3c6dbe6efc3fac683713ff66b8ba6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:15:27 +0000 Subject: [PATCH 6/6] fix(spec): keep defineForm's option-value refusal an Error with a stack The refusal was rebuilt with `new z.ZodError(...)`, which in zod v4 classic is a trait object with no `Error` parent and no `stack`: an uncaught module-load throw printed `ZodError { name, message: [Getter/Setter] }` and hid the issues and the remedy. It is now a `z.ZodRealError` built from the remedied issue copies, with its trace captured at the `defineForm` call (zod builds every ZodRealError with `Error.stackTraceLimit = 0` and captures a trace only in `parse`, so the parse's own `safeParse` error carries no frame either). The refusal pin now asserts an `Error`, a string `stack`, and a frame in the calling module; a new case reads the printed wall (the stack's head) for the grammar message and the remedy. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude --- .../src/ui/form-option-enum-derive.test.ts | 31 ++++++++++++++++++- packages/spec/src/ui/view.zod.ts | 13 +++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/form-option-enum-derive.test.ts b/packages/spec/src/ui/form-option-enum-derive.test.ts index f52f212cfe..967ec543c1 100644 --- a/packages/spec/src/ui/form-option-enum-derive.test.ts +++ b/packages/spec/src/ui/form-option-enum-derive.test.ts @@ -108,7 +108,20 @@ function formWithRow(schemaId: string, row: Record) { }); } -/** The ZodError a build threw, or a loud failure when it built. */ +/** This file's own name — the module every `defineForm(...)` call below is made from. */ +const THIS_MODULE = 'form-option-enum-derive.test.ts'; + +/** + * The ZodError a build threw, or a loud failure when it built. + * + * The refusal must be an `Error` with a stack, or an uncaught module-load + * throw prints `ZodError { name, message: [Getter/Setter] }` and nothing else: + * `new z.ZodError(...)` is a zod trait object, not an `Error`, and + * `toBeInstanceOf(z.ZodError)` passes on it all the same. A string `stack` is + * not enough either — zod builds `parsed.error` and a bare + * `new z.ZodRealError(...)` with no frame at all — so the stack must also name + * the module that called `defineForm`. + */ function refusal(build: () => unknown): z.ZodError { let thrown: unknown; try { @@ -117,6 +130,9 @@ function refusal(build: () => unknown): z.ZodError { thrown = e; } expect(thrown, 'expected defineForm to REFUSE at module load').toBeInstanceOf(z.ZodError); + expect(thrown).toBeInstanceOf(Error); + expect(typeof (thrown as Error).stack).toBe('string'); + expect((thrown as Error).stack, 'the stack names no frame in the module that called defineForm').toContain(THIS_MODULE); return thrown as z.ZodError; } @@ -201,6 +217,19 @@ describe('defineForm: the refusal of an unspellable option value names the deriv expect(statesBlanketOmitRule('When this row edits a spec enum, omit `options`: …')).toBe(true); }); + it('the wall an uncaught throw prints (the stack) carries the grammar message and the remedy', () => { + // Node prints an uncaught `Error` by its `stack`, whose first line is + // `name: message`, and a ZodError's message is its issue list as JSON. + const stack = refusal(formWithOption('new-tab')).stack ?? ''; + const firstFrame = stack.search(/\n\s+at /); + const head = firstFrame < 0 ? stack : stack.slice(0, firstFrame); + expect(head.startsWith('ZodError: '), head.slice(0, 80)).toBe(true); + // Today's grammar message, read live off the object face and JSON-escaped as the message prints it. + expect(head).toContain(JSON.stringify(objectFaceMessage('new-tab')).slice(1, -1)); + expect(namesDerivePath(head), head).toBe(true); + expect(scopesDeriveToUnspellable(head), head).toBe(true); + }); + it('a nested row (composite `fields`) gets the same remedy', () => { const thrown = refusal(() => defineForm({ schemaId: 'action', diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index d6471f538d..e2feed0106 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -6369,7 +6369,18 @@ export function defineForm( data: { provider: 'schema', schemaId }, }); if (parsed.success) return parsed.data; - throw new z.ZodError(withOptionValueDeriveRemedy(parsed.error.issues)); + // The refusal stays an `Error` with a stack, so an uncaught module-load throw + // prints its issues and the remedy, with the author's `defineForm(...)` call + // as the first frame. Two traps, both measured on zod 4.6.1: `new z.ZodError` + // builds a plain object (no `Error` parent, no `stack`), which node prints as + // `ZodError { name, message: [Getter/Setter] }`; and zod builds every + // `ZodRealError` with `Error.stackTraceLimit = 0`, capturing a trace only in + // `parse`, so `parsed.error` or a bare `new z.ZodRealError` carries no frame. + // The error is built from issues that already carry the remedy, so its + // lazily computed `message` holds it whenever it is first read. + const refusal = new z.ZodRealError(withOptionValueDeriveRemedy(parsed.error.issues)); + z.core.util.captureStackTrace(refusal, defineForm); + throw refusal; } /**