diff --git a/.changeset/form-view-option-default-narrowed.md b/.changeset/form-view-option-default-narrowed.md new file mode 100644 index 0000000000..4e0a423cec --- /dev/null +++ b/.changeset/form-view-option-default-narrowed.md @@ -0,0 +1,44 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): narrow the per-option `default` key OUT of the form-view options vocabulary — the object-field face keeps it enforced (#12868) + +**BREAKING** accept-set narrowing on the published FormView vocabulary, shipped +as `minor` under the repo's launch-window convention for breaking changes. + + + +`SelectOptionSchema` serves two surfaces, and only one of them reads the +per-option `default` key: + +- **Object-field options** (`Field.select.options`): ENFORCED and UNTOUCHED — + `applyFieldDefaults` falls back to the option marked `default: true` when the + field declares no `defaultValue`, `defaultValue` wins when both are declared, + and the alias rows (`isDefault`/`selected` → `default`) stay. +- **Form-view options** (`FormFieldSchema.options` inside a FormView): the same + key parsed clean and nothing read it — the engine's insert-path fallback + consults the OBJECT definition's options, never a form view's, and no form + renderer seeds a value from it. The maintainer-ruled disposition (2026-08-28, + disposition 甲) narrows the key out of this face only. The ruled census + measured ZERO occurrences of `default` (and the alias spellings + `isDefault`/`selected`) inside form-view options across the tree, the example + apps and the published `*.form.ts` corpus, with the instrument's positive + control hitting the enforced object-field usages. + +FROM → TO, and the one-line fix: + +- FROM: `{ field: 'status', type: 'select', options: [{ label: 'Open', value: 'open', default: true }] }` + inside a form view's `sections[].fields[]` (or nested `fields`) — parsed + clean, did nothing. +- TO: delete the key from the form-view option. Declare the pre-selected choice + on the OBJECT definition instead — field-level `defaultValue`, or + `default: true` on that field's own `options` entry (both enforced there). +- The new `FormSelectOptionSchema` (an Omit-derivation of `SelectOptionSchema` + minus exactly `default`) refuses the key with a tombstone prescription + carrying this mapping; `isDefault`/`selected` get guidance pointing at the + object definition instead of a rename toward a key the shape refuses. +- The protocol-18 conversion `form-view-option-default-removed` strips the key + from stored sources (pure lossless delete — it never had an effect on this + surface to lose). Run `os migrate meta --from 17` to list the mechanical + edits for existing sources; apply them by hand. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 1fa890ef0a..ddb847e0bb 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1604 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1605 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 8 | 32 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 152 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **201** | **1604** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **201** | **1605** | 14 protocol modules | --- @@ -367,7 +367,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 152 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 153 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -388,7 +388,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` | | [`responsive.zod.ts`](/docs/references/ui/responsive) | `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | -| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormFieldPublicPicker`, `FormSection`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | +| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormFieldPublicPicker`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index b2d9e5a14c..050b332374 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -15,8 +15,8 @@ Migrated to [shared/http.zod.ts](/docs/references/shared/http). Re-exported here ## TypeScript Usage ```typescript -import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, FormButtonConfigSchema, FormFieldSchema, FormFieldPublicPickerSchema, FormSectionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, FormButtonConfig, FormField, FormFieldPublicPicker, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, FormButtonConfigSchema, FormFieldSchema, FormFieldPublicPickerSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, FormButtonConfig, FormField, FormFieldPublicPicker, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfigSchema.parse(data); @@ -137,7 +137,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; color?: string; default?: boolean; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields | +| **options** | `{ label: string; value: string; color?: string; visibleWhen?: string \| object }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition) | | **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) | @@ -219,12 +219,13 @@ Column footer summary configuration ### Nested Shape: `FormField.options[number]` +Form-view select option — the object-field option shape minus the per-option `default` key (declare the pre-selected choice on the object definition: field-level `defaultValue`, or `default: true` on that field's own `options` entry). + | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **label** | `string` | ✅ | Display label (human-readable, any case allowed) | | **value** | `string` | ✅ | Stored value (lowercase machine identifier) | | **color** | `string` | optional | Color code for badges/charts | -| **default** | `boolean` | optional | Is default option | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user` — wider than field-level visibleWhen, which has no `current_user`. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | ### Nested Shape: `FormField.publicPicker` @@ -299,7 +300,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; color?: string; default?: boolean; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields | +| **options** | `{ label: string; value: string; color?: string; visibleWhen?: string \| object }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition) | | **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) | @@ -328,6 +329,22 @@ View filter rule | **fields** | `{ field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … }[]` | optional | Sub-fields for composite/repeater/record types | +--- + +## FormSelectOption + +Form-view select option — the object-field option shape minus the per-option `default` key (declare the pre-selected choice on the object definition: field-level `defaultValue`, or `default: true` on that field's own `options` entry). + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | ✅ | Display label (human-readable, any case allowed) | +| **value** | `string` | ✅ | Stored value (lowercase machine identifier) | +| **color** | `string` | optional | Color code for badges/charts | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user` — wider than field-level visibleWhen, which has no `current_user`. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | + + --- ## FormView diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 9cd7a480d2..dfd18ba561 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 436 | +| Object sites in them | 437 | | Still-open (strip) sites | 124 | | Files carrying at least one | 22 | @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 168 | 156 | 5 | 0 | 7 | +| `ui/` | 169 | 157 | 5 | 0 | 7 | | `data/` | 156 | 74 | 1 | 0 | 81 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **436** | **306** | **6** | **0** | **124** | +| **total** | **437** | **307** | **6** | **0** | **124** | ## File-level triage — site counts @@ -74,9 +74,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 3 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | -| `view.zod.ts` | 57 | +| `view.zod.ts` | 58 | | `widget.zod.ts` | 1 | -| **total** | **168** | +| **total** | **169** | ### `data/` — sites @@ -155,15 +155,15 @@ over it is here. ### `ui/` — open -**7 strip of 168**, in 4 file(s). +**7 strip of 169**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 18 | -| `view.zod.ts` | 4 | 57 | +| `view.zod.ts` | 4 | 58 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **168** | +| **total** | **7** | **169** | | Bucket | Sites | |---|---| diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index a81ecdfc09..74b169d406 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -160,6 +160,9 @@ "FormSection (type)", "FormSectionParsed (type)", "FormSectionSchema (const)", + "FormSelectOption (type)", + "FormSelectOptionParsed (type)", + "FormSelectOptionSchema (const)", "FormView (type)", "FormViewParsed (type)", "FormViewSchema (const)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 3247344c83..a688fb442a 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -447,6 +447,10 @@ "ui/FormSection:pane", "ui/FormSection:visibleOn", "ui/FormSection:visibleWhen", + "ui/FormSelectOption:color", + "ui/FormSelectOption:label", + "ui/FormSelectOption:value", + "ui/FormSelectOption:visibleWhen", "ui/FormView:allowSkip", "ui/FormView:aria [RETIRED]", "ui/FormView:buttons", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 30fd33aa29..e73c6dab6c 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -160,6 +160,9 @@ "FormSection": "src/ui/view.zod.ts#FormSection (type)", "FormSectionParsed": "src/ui/view.zod.ts#FormSectionParsed (type)", "FormSectionSchema": "src/ui/view.zod.ts#FormSectionSchema (const)", + "FormSelectOption": "src/ui/view.zod.ts#FormSelectOption (type)", + "FormSelectOptionParsed": "src/ui/view.zod.ts#FormSelectOptionParsed (type)", + "FormSelectOptionSchema": "src/ui/view.zod.ts#FormSelectOptionSchema (const)", "FormView": "src/ui/view.zod.ts#FormView (type)", "FormViewParsed": "src/ui/view.zod.ts#FormViewParsed (type)", "FormViewSchema": "src/ui/view.zod.ts#FormViewSchema (const)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 2359646dad..89529d0144 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -60,6 +60,7 @@ "ui/FormField", "ui/FormFieldPublicPicker", "ui/FormSection", + "ui/FormSelectOption", "ui/FormView", "ui/GalleryConfig", "ui/GanttConfig", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index f915734073..c7f5f91d10 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8193,6 +8193,215 @@ const permissionAllowRestorePurgeRemoved: MetadataConversion = { }, }; +/** + * [#12868] The per-option `default` key leaves the FORM-VIEW options + * vocabulary (protocol 18; maintainer-ruled narrowing 2026-08-28 on the + * objectui#6263 analysis, disposition 甲). + * + * `SelectOptionSchema` serves two surfaces and only one reads the key: on an + * OBJECT field's option list `default` is ENFORCED (#7246 / PR #7388 — + * `applyFieldDefaults` falls back to the option marked `default: true`), and + * that face is untouched by this conversion. On a form-view field's option + * list the key parsed clean and nothing read it — the engine's insert-path + * fallback consults the object definition's options, never a form view's, and + * no form renderer seeds a value from it (measured on objectui#6263). The + * FormView vocabulary now refuses the key (`FormSelectOptionSchema`, + * `ui/view.zod.ts`) with the prescription; this entry strips it from stored + * sources, a pure lossless delete (it never had an effect on this surface to + * lose). + * + * Walks the same containers as `view-visibleOn-to-visibleWhen`: `views[].form` + * and `views[].formViews.*`, through `sections[]`/`groups[]` and top-level + * `fields[]`, recursing into nested `fields` (composite/repeater/record rows + * carry their own option lists). Only the exact key `default` is stripped — + * the alias spellings `isDefault`/`selected` were never accepted on this + * surface (the option shape has been strict since it closed), so there is + * nothing stored to strip for them. + */ +const formViewOptionDefaultRemoved: MetadataConversion = { + id: 'form-view-option-default-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'view.form.sections[].fields[].options[].default', + summary: + "form-view per-option 'default' removed from the FormView vocabulary (ADR-0049 " + + 'declared-but-unenforced — nothing on the form path read it: the insert-path default ' + + "falls back to the OBJECT definition's option list, and no form renderer seeds a value " + + "from a form view's. The object field option's 'default' stays enforced; declare the " + + "pre-selected choice there — field-level 'defaultValue', or 'default: true' on that " + + "field's own options entry)", + apply(stack, emit) { + const stripOptionDefault = (options: unknown, path: string): unknown => { + if (!Array.isArray(options)) return options; + let changed = false; + const next = options.map((opt, i) => { + if (!isDict(opt)) return opt; + const stripped = stripKeys(opt, ['default'], emit, `${path}[${i}]`); + if (stripped !== opt) changed = true; + return stripped; + }); + return changed ? next : options; + }; + const mapFields = (fields: unknown, path: string): unknown => { + if (!Array.isArray(fields)) return fields; + let changed = false; + const next = fields.map((field, i) => { + if (!isDict(field)) return field; + let dict: Dict = field; + const options = stripOptionDefault(dict.options, `${path}[${i}].options`); + if (options !== dict.options) dict = { ...dict, options }; + const nested = mapFields(dict.fields, `${path}[${i}].fields`); + if (nested !== dict.fields) dict = { ...dict, fields: nested }; + if (dict !== field) changed = true; + return dict; + }); + return changed ? next : fields; + }; + const mapSections = (sections: unknown, path: string): unknown => { + if (!Array.isArray(sections)) return sections; + let changed = false; + const next = sections.map((section, i) => { + if (!isDict(section)) return section; + let dict: Dict = section; + const fields = mapFields(dict.fields, `${path}[${i}].fields`); + if (fields !== dict.fields) dict = { ...dict, fields }; + if (dict !== section) changed = true; + return dict; + }); + return changed ? next : sections; + }; + const mapForm = (form: unknown, path: string): unknown => { + if (!isDict(form)) return form; + let dict: Dict = form; + for (const key of ['sections', 'groups'] as const) { + const mapped = mapSections(dict[key], `${path}.${key}`); + if (mapped !== dict[key]) dict = { ...dict, [key]: mapped }; + } + const fields = mapFields(dict.fields, `${path}.fields`); + if (fields !== dict.fields) dict = { ...dict, fields }; + return dict; + }; + return mapCollection(stack, 'views', (view, path) => { + let next = view; + const form = mapForm(next.form, `${path}.form`); + if (form !== next.form) next = { ...next, form }; + const formViews = next.formViews; + if (isDict(formViews)) { + let fvChanged = false; + const nextViews: Record = {}; + for (const [name, fv] of Object.entries(formViews)) { + const mapped = mapForm(fv, `${path}.formViews.${name}`); + if (mapped !== fv) fvChanged = true; + nextViews[name] = mapped; + } + if (fvChanged) next = { ...next, formViews: nextViews }; + } + return next; + }); + }, + fixture: { + before: { + views: [{ + object: 'crm_task', + form: { + sections: [{ + label: 'Details', + fields: [ + // The measured authored shape: a select row whose option carries + // the key nothing on the form path read. + { + field: 'status', + type: 'select', + options: [ + { label: 'Open', value: 'open', default: true }, + { label: 'Closed', value: 'closed' }, + ], + }, + // A nested row (composite) — options recurse through `fields`. + { + field: 'meta', + type: 'composite', + fields: [ + { + field: 'priority', + type: 'radio', + options: [ + { label: 'Low', value: 'low' }, + { label: 'High', value: 'high', default: true }, + ], + }, + ], + }, + ], + }], + }, + formViews: { + quick: { + fields: [{ + field: 'channel', + type: 'select', + options: [ + { label: 'Email', value: 'email', default: true }, + // An option WITHOUT the key rides through untouched. + { label: 'Phone', value: 'phone' }, + ], + }], + }, + }, + }], + }, + after: { + views: [{ + object: 'crm_task', + form: { + sections: [{ + label: 'Details', + fields: [ + { + field: 'status', + type: 'select', + options: [ + { label: 'Open', value: 'open' }, + { label: 'Closed', value: 'closed' }, + ], + }, + { + field: 'meta', + type: 'composite', + fields: [ + { + field: 'priority', + type: 'radio', + options: [ + { label: 'Low', value: 'low' }, + { label: 'High', value: 'high' }, + ], + }, + ], + }, + ], + }], + }, + formViews: { + quick: { + fields: [{ + field: 'channel', + type: 'select', + options: [ + { label: 'Email', value: 'email' }, + { label: 'Phone', value: 'phone' }, + ], + }], + }, + }, + }], + }, + // Three notices: one stripped key per option carrying it — the `form` + // section row, its nested composite row, and the named `formViews` entry. + expectedNotices: 3, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -8279,6 +8488,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { success: boolean; error?: unknown } }, + value: unknown, +): string { + const r = schema.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + const issues = (r.error as { issues?: Array<{ code?: string; message?: string }> }).issues ?? []; + const hit = issues.find((i) => i.code === 'unrecognized_keys'); + expect(hit, `no \`unrecognized_keys\` issue in ${JSON.stringify(issues)}`).toBeDefined(); + return hit?.message ?? ''; +} + +/** Minimal valid option body (`value` is a SystemIdentifier — min 2 chars). */ +const OPTION = { label: 'Open', value: 'open' } as const; + +// =========================================================================== +// 1. The refused half — form-view options reject `default`, with the +// prescription (ADR-0112-adjacent: assert the message, not just the throw) +// =========================================================================== + +describe('form-view options refuse the per-option `default` key', () => { + it('`default` is refused with the tombstone prescription', () => { + const m = unknownKeyMessage(FormSelectOptionSchema, { ...OPTION, default: true }); + expect(m).toMatch(/`options\[\]\.default` on a form-view field was removed from the FormView vocabulary/); + // The prescription names BOTH enforced destinations and their precedence. + expect(m).toContain('field-level `defaultValue`'); + expect(m).toContain('`default: true`'); + expect(m).toMatch(/`defaultValue` winning when both are declared/); + // House migrate sentence (route D wording — a property of the tool). + expect(m).toContain('Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'); + // No rename suggestion toward a key this shape refuses. + expect(m).not.toContain('Did you mean'); + }); + + it.each(['isDefault', 'selected'] as const)('the object-face alias spelling `%s` gets guidance, not a rename', (key) => { + const m = unknownKeyMessage(FormSelectOptionSchema, { ...OPTION, [key]: true }); + expect(m).toContain(`\`${key}\` is an object-field spelling`); + expect(m).toContain('declare the pre-selected choice on the object definition'); + // A rename would point at a key this shape refuses (the `triggerPhrases` + // failure shape) — guidance must have replaced it. + expect(m).not.toContain('Did you mean'); + }); + + it('the refusal fires through the real doors — a form field row and a whole FormView', () => { + const row = { + field: 'status', + type: 'select', + options: [{ ...OPTION, default: true }, { label: 'Closed', value: 'closed' }], + }; + const viaField = FormFieldSchema.safeParse(row); + expect(viaField.success).toBe(false); + const flat = JSON.stringify((viaField as { error?: { issues?: unknown } }).error?.issues ?? []); + expect(flat).toContain('removed from the FormView vocabulary'); + + const viaForm = FormViewSchema.safeParse({ sections: [{ label: 'Details', fields: [row] }] }); + expect(viaForm.success).toBe(false); + // The union door nests the issue; the prescription must still be there. + expect(JSON.stringify((viaForm as { error?: { issues?: unknown } }).error?.issues ?? [])) + .toContain('removed from the FormView vocabulary'); + }); + + it('the refusal recurses into nested sub-field rows (composite/repeater/record)', () => { + const nested = FormFieldSchema.safeParse({ + field: 'meta', + type: 'composite', + fields: [{ + field: 'priority', + type: 'radio', + options: [{ label: 'High', value: 'high', isDefault: true }], + }], + }); + expect(nested.success).toBe(false); + expect(JSON.stringify((nested as { error?: { issues?: unknown } }).error?.issues ?? [])) + .toContain('object-field spelling'); + }); + + it('an option WITHOUT the key parses green through the whole FormView — the narrowing removed one key, not the vocabulary', () => { + const r = FormViewSchema.safeParse({ + sections: [{ + label: 'Details', + fields: [{ + field: 'status', + type: 'select', + options: [ + { label: 'Open', value: 'open', color: '#3B82F6', visibleWhen: "record.country == 'cn'" }, + { label: 'Closed', value: 'closed' }, + ], + }], + }], + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error ?? {})).toBe(true); + }); +}); + +// =========================================================================== +// 2. The untouched half — the OBJECT-field face still accepts `default` +// =========================================================================== + +describe('the object-field face is untouched', () => { + it('`SelectOptionSchema` still accepts `default: true` (the enforced face)', () => { + const r = SelectOptionSchema.safeParse({ ...OPTION, default: true }); + expect(r.success).toBe(true); + expect((r as { data?: { default?: boolean } }).data?.default).toBe(true); + }); + + it('a whole object field still accepts a defaulted option through `FieldSchema`', () => { + const r = FieldSchema.safeParse({ + name: 'status', + label: 'Status', + type: 'select', + options: [{ label: 'Open', value: 'open', default: true }, { label: 'Closed', value: 'closed' }], + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error ?? {})).toBe(true); + }); + + it('the object face keeps its alias rows for the spellings the form face refuses', () => { + // Forcing the parse first registers the declaration (lazySchema). + SelectOptionSchema.safeParse(OPTION); + const decl = strictObjectDeclarations().find((d) => d.options.surface === 'this select option'); + expect(decl, '`SelectOptionSchema` did not register a declaration').toBeDefined(); + expect(decl!.options.aliases?.isDefault).toBe('default'); + expect(decl!.options.aliases?.selected).toBe('default'); + }); +}); + +// =========================================================================== +// 3. Derivation pins — the two faces cannot drift apart key-by-key +// =========================================================================== + +describe('FormSelectOptionSchema is an Omit of SelectOptionSchema', () => { + it('shape = the object face minus exactly `default`', () => { + const objectKeys = Object.keys(SelectOptionSchema.shape).sort(); + const formKeys = Object.keys(FormSelectOptionSchema.shape).sort(); + expect(objectKeys).toContain('default'); + expect(formKeys).toEqual(objectKeys.filter((k) => k !== 'default')); + }); + + it('property schemas are shared BY REFERENCE — an Omit, not a copy', () => { + for (const key of Object.keys(FormSelectOptionSchema.shape)) { + expect( + FormSelectOptionSchema.shape[key as keyof typeof FormSelectOptionSchema.shape], + `\`${key}\` must be the object face's own property schema`, + ).toBe(SelectOptionSchema.shape[key as keyof typeof SelectOptionSchema.shape]); + } + }); + + it('alias table = the object face minus the rows that pointed at `default`; those spellings moved to guidance', () => { + FormSelectOptionSchema.safeParse(OPTION); + SelectOptionSchema.safeParse(OPTION); + const decls = strictObjectDeclarations(); + const objectDecl = decls.find((d) => d.options.surface === 'this select option'); + const formDecl = decls.find((d) => d.options.surface === 'this form-view select option'); + expect(objectDecl).toBeDefined(); + expect(formDecl, '`FormSelectOptionSchema` did not register a declaration').toBeDefined(); + + const objectAliases = objectDecl!.options.aliases ?? {}; + const removedRows = Object.entries(objectAliases).filter(([, target]) => target === 'default'); + expect(removedRows.map(([k]) => k).sort()).toEqual(['isDefault', 'selected']); + + const expectedFormAliases = Object.fromEntries( + Object.entries(objectAliases).filter(([, target]) => target !== 'default'), + ); + expect(formDecl!.options.aliases).toEqual(expectedFormAliases); + + // The removed key and its two spellings are all answered by guidance. + expect(Object.keys(formDecl!.options.guidance ?? {}).sort()).toEqual(['default', 'isDefault', 'selected']); + }); + + it('the editability boundary rides along (one vocabulary, per-shape answer)', () => { + FormSelectOptionSchema.safeParse(OPTION); + const formDecl = strictObjectDeclarations().find((d) => d.options.surface === 'this form-view select option'); + expect(formDecl!.options.guidanceSets?.map((s) => s.name)) + .toContain('SELECT_OPTION_EDITABILITY_BOUNDARY_KEYS'); + // The verdict is unchanged too, not just the message. + expect(FormSelectOptionSchema.safeParse({ ...OPTION, disabled: true }).success).toBe(false); + }); + + it('the surviving rename channel still fires on the form face', () => { + expect(unknownKeyMessage(FormSelectOptionSchema, { ...OPTION, colour: 'red' })) + .toContain('Did you mean `colour` → `color`?'); + expect(unknownKeyMessage(FormSelectOptionSchema, { ...OPTION, title: 'X' })) + .toContain('Did you mean `title` → `label`?'); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 80dc2ea8aa..ec68b44956 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -7,7 +7,7 @@ import { strictObject, strictObjectError } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility'; -import { VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; +import { SELECT_OPTION_EDITABILITY_GUIDANCE, VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ChartTypeSchema } from './chart.zod'; import { SharingConfigSchema } from './sharing.zod'; @@ -1695,6 +1695,92 @@ export const ListViewSchema = lazySchema(() => strictObject({ ), })); +/** + * [#12868] Form-view select option — {@link SelectOptionSchema} minus the + * per-option `default` key (maintainer-ruled narrowing, 2026-08-28, on the + * objectui#6263 analysis; disposition 甲). + * + * `SelectOptionSchema` serves two surfaces, and only ONE of them reads + * `default`: + * + * - **Object-field options** (`Field.select.options`): `default` is ENFORCED — + * `applyFieldDefaults` falls back to the option marked `default: true` when + * the field declares no `defaultValue` (#7246 ruling, PR #7388; `defaultValue` + * wins when both are declared). That face keeps the key, its alias rows + * (`isDefault`/`selected` → `default`) and its precedence pin, untouched. + * - **Form-view options** (this shape): the key parsed clean and nothing read + * it — the engine's insert-path fallback consults the OBJECT definition's + * options, never a form view's, and no form renderer seeds a value from it + * (measured on objectui#6263: all three consuming controls drop it). A second + * default contract on the console was explicitly declined in the #7246 + * ruling's rider, so the ADR-0049 declared-but-unenforced key is narrowed OUT + * of this face only. + * + * Derived structurally from `SelectOptionSchema.shape` (an Omit, not a copy) so + * the two faces cannot drift apart key-by-key: every key the object-field + * option shape gains or loses is gained or lost here too, minus exactly + * `default`. The alias table is the object face's minus the two rows that + * pointed at the removed key; those spellings move to `guidance`, because an + * alias row must name a key the shape accepts (`alias-integrity.test.ts`) and + * suggesting a key the schema refuses is the `triggerPhrases` failure shape + * `shared/strict-object.ts` documents. The census that gates this narrowing + * (issue #12868, part of the ruling) measured ZERO occurrences of + * `default`/`isDefault`/`selected` inside form-view options across this tree, + * the example apps and the published `*.form.ts` corpus, with the instrument's + * positive control hitting the 40+ enforced object-field usages. + * + * The protocol-18 conversion `form-view-option-default-removed` strips the key + * from stored sources; the guidance below carries the author-facing + * prescription. + */ +export const FormSelectOptionSchema = lazySchema(() => { + // An Omit at the SHAPE level: reuse every property schema by reference, + // drop exactly `default`. (`.omit()` would also work, but building through + // `strictObject` is what registers this surface's own aliases/guidance with + // the alias-integrity audit instead of inheriting the object face's.) + const { default: _objectFaceOnly, ...formOptionShape } = SelectOptionSchema.shape; + return strictObject({ + surface: 'this form-view select option', + history: + 'Until the FormView vocabulary got its own option shape these keys were judged by the ' + + 'shared object-field option schema, so `default` parsed clean here while nothing on the ' + + 'form path read it.', + // The object face's alias table minus the two rows that pointed at + // `default` — an alias must prescribe a key the shape accepts, and those + // two spellings are answered by `guidance` below instead. + aliases: { text: 'label', name: 'label', title: 'label', key: 'value', id: 'value', colour: 'color', visible: 'visibleWhen', showWhen: 'visibleWhen' }, + guidance: { + default: + '`options[].default` on a form-view field was removed from the FormView vocabulary in ' + + '@objectstack/spec 18 (ADR-0049 declared-but-unenforced) — on this surface the key ' + + 'parsed clean and nothing read it: the insert-path default falls back to the OBJECT ' + + "definition's option list, never a form view's, and no form renderer seeds a value " + + 'from it. Delete the key. Declare the pre-selected choice on the object definition ' + + 'instead — field-level `defaultValue`, or `default: true` on that field\'s own ' + + '`options` entry (both enforced there, with `defaultValue` winning when both are ' + + 'declared). Run `os migrate meta --from 17` to list the mechanical edits for existing ' + + 'sources; apply them by hand.', + isDefault: + '`isDefault` is an object-field spelling: an OBJECT field\'s option list answers it with ' + + 'a rename to `default`, which is enforced there. Form-view options accept neither ' + + 'spelling — declare the pre-selected choice on the object definition (field-level ' + + '`defaultValue`, or `default: true` on that field\'s own `options` entry).', + selected: + '`selected` is an object-field spelling: an OBJECT field\'s option list answers it with ' + + 'a rename to `default`, which is enforced there. Form-view options accept neither ' + + 'spelling — declare the pre-selected choice on the object definition (field-level ' + + '`defaultValue`, or `default: true` on that field\'s own `options` entry).', + }, + // Same editability boundary as the object face (#8201): an option is + // offered or withheld, never shown-but-unselectable. + guidanceSets: [SELECT_OPTION_EDITABILITY_GUIDANCE], + }, formOptionShape).describe( + 'Form-view select option — the object-field option shape minus the per-option `default` key ' + + '(declare the pre-selected choice on the object definition: field-level `defaultValue`, or ' + + "`default: true` on that field's own `options` entry).", + ); +}); + /** * Non-recursive half of {@link FormFieldSchema} — every key except the * recursive `fields`. @@ -1843,8 +1929,14 @@ const FormFieldBaseSchema = lazySchema(() => { /** Field type — reuses Data.FieldType. When set, widget is auto-inferred (can be overridden). */ type: FieldType.optional().describe('Field type (auto-infers widget if omitted)'), - /** Select/multiselect options — only needed when type=select/multiselect/radio/checkboxes */ - options: z.array(SelectOptionSchema).optional().describe('Options for select/multiselect/radio/checkboxes fields'), + /** + * Select/multiselect options — only needed when type=select/multiselect/radio/checkboxes. + * + * [#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. + */ + 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)'), /** Reference object for lookup/master_detail fields */ reference: z.string().optional().describe('Target object name for lookup/master_detail fields'), @@ -4465,6 +4557,10 @@ export type FormViewParsed = z.infer; export type FormSection = z.input; /** Post-parse shape of {@link FormSection} — defaults applied, transforms run (ADR-0122). */ export type FormSectionParsed = z.infer; +/** Authoring shape of {@link FormSelectOptionSchema} — the object-field option minus `default` (#12868). */ +export type FormSelectOption = z.input; +/** Post-parse shape of {@link FormSelectOption} — defaults applied, transforms run (ADR-0122). */ +export type FormSelectOptionParsed = z.infer; export type ListColumn = z.input; /** Post-parse shape of {@link ListColumn} — defaults applied, transforms run (ADR-0122). */ export type ListColumnParsed = z.infer;