diff --git a/.changeset/23809-select-option-kind.md b/.changeset/23809-select-option-kind.md new file mode 100644 index 00000000000..1b14b9febe0 --- /dev/null +++ b/.changeset/23809-select-option-kind.md @@ -0,0 +1,34 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': patch +'@objectstack/metadata-core': patch +--- + +feat(spec)!: a select option declares its lifecycle `kind` (`open` / `won` / `lost`), and every option of an object's `stageField` must declare it + +Clause-②: yes (narrowing) + +`SelectOptionSchema` gains one optional key, `kind`, a closed vocabulary of three lifecycle categories that means the same on every object: `open` (a step on the way), `won` (the successful end: a deal's Closed Won, a lead's Converted, a task's Done, a case's Resolved) and `lost` (the unsuccessful end: Closed Lost, Unqualified, Cancelled). It is the spelling `record:path` `stages[].terminal` already takes, so the data layer and the page say one thing one way. Example: `{ label: 'Closed Lost', value: 'closed_lost', kind: 'lost' }`. The exported type `SelectOptionKind` names the three values. + +- On a plain single-value `select` / `radio` the key is optional, and an absent kind reads as `open`. Nothing is written in at parse: an option without `kind` parses without it. +- On any other field's options (`multiselect`, `checkboxes`, `tags`, a `multiple: true` select, a lookup's static options) `FieldSchema` refuses it at `options..kind`: a lifecycle is one value per record. +- A shared `picklist`'s options accept it (the same option shape). A form view's option list does not (`FormSelectOptionSchema`): it is refused there as an unknown key whose message points at the object field's own option. + +**BREAKING (authoring):** when an object's `stageField` names a field, every option of that field must declare `kind`. `ObjectSchema` refuses the object otherwise, at every door that parses one (`ObjectSchema.create()`, `defineStack`, `os validate`, `os build`, the metadata save door), with one issue per option at `fields..options..kind` naming the field and the option. There is no default under `stageField`. Also refused, at `stageField`: a stage field that takes its options from a `picklist` (the object cannot see them to check), and a stage field carrying options that holds no single option code per record (`multiselect`, a `multiple: true` select). Unchanged: `stageField: false`, a `stageField` naming no declared field, and one naming a field with no options. + +Migration: add `kind` to each option of the stage field, marking each terminal state `won` or `lost` and every other state `open`: + +- `status: Field.select({ options: [{ label: 'New', value: 'new' }, { label: 'Converted', value: 'converted' }, { label: 'Unqualified', value: 'unqualified' }] })` with `stageField: 'status'` → the same options with `kind: 'open'`, `kind: 'won'` and `kind: 'lost'`. +- A picklist-backed stage field → declare its options inline on the field. +- An object with no linear lifecycle → `stageField: false`. + +No conversion makes this edit, because which option ends a record's life successfully is the author's to say. `os migrate meta` lists the D3 entry `stage-field-option-kind-required` with the prescription. + +The Studio object designer's options repeater offers `kind`; the field form's options repeater lists it. + +The other packages follow the new key: + +- **`@objectstack/platform-objects`.** The metadata-form translation bundles (`./metadata-translations`) carry the options repeater's new `kind` row, with its label and help text in zh-CN, ja-JP and es-ES. +- **`@objectstack/metadata-core`.** The published contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) names `salary_grade` as its `stageField`, so its two options now declare `kind: 'open'`, and the fixture meets the stage-field rule: `ObjectSchema` raises no issue at `stageField` or under `fields.salary_grade.options`. The fixture is not otherwise a spec-valid object (its `script` actions carry no `body` or `target`), and this change leaves that as it was. + + diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index fb3353566e9..73c1d7986ba 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -963,7 +963,7 @@ Metadata query with filtering, sorting, and pagination | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | +| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index aa56d333810..18fed0b59b5 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -201,6 +201,7 @@ const result = CurrencyConfigSchema.parse(data); | **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`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | +| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field. | ### Nested Shape: `Field.referenceDeletedWhen` @@ -426,6 +427,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name` | **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`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | +| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field. | --- diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 590b26752aa..61038958a89 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -167,7 +167,7 @@ const result = ApiMethod.parse(data); | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | +| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | diff --git a/content/docs/references/data/picklist.mdx b/content/docs/references/data/picklist.mdx index 138d99fedf1..90b47126701 100644 --- a/content/docs/references/data/picklist.mdx +++ b/content/docs/references/data/picklist.mdx @@ -79,6 +79,7 @@ A shared option list that select fields reference by name instead of copying opt | **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`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | +| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field. | --- @@ -104,6 +105,7 @@ Options added to a picklist owned by another package (additive only — the `obj | **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`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | +| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field. | --- @@ -129,6 +131,7 @@ The served form of a picklist-bound field: `options` resolved, `picklist` kept | **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`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | +| **kind** | `Enum<'open' \| 'won' \| 'lost'>` | optional | Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field. | --- diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index db1a163c327..c143b19eb25 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -349,7 +349,7 @@ Create a new object | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | +| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | @@ -642,7 +642,7 @@ Create a new object | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | -| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | +| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 19a3240c7cb..6d04b33f540 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -285,7 +285,7 @@ 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). +Form-view select option — the object-field option shape minus the per-option `default` and `kind` keys (declare the pre-selected choice on the object definition: field-level `defaultValue`, or `default: true` on that field's own `options` entry; declare `kind` there too). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -366,7 +366,7 @@ Form-view select option — the object-field option shape minus the per-option ` ## 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). +Form-view select option — the object-field option shape minus the per-option `default` and `kind` keys (declare the pre-selected choice on the object definition: field-level `defaultValue`, or `default: true` on that field's own `options` entry; declare `kind` there too). ### Properties diff --git a/examples/app-showcase/src/data/objects/semantic-zoo.object.ts b/examples/app-showcase/src/data/objects/semantic-zoo.object.ts index 97f0d4c22ff..938574b217f 100644 --- a/examples/app-showcase/src/data/objects/semantic-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/semantic-zoo.object.ts @@ -35,10 +35,12 @@ export const SemanticZoo = ObjectSchema.create({ name: Field.text({ label: 'Name', required: true }), status: Field.select({ label: 'Status', + // `stageField: 'status'` below makes `kind` mandatory on every option + // (#23809): `done` is the successful end, drawn on the forward path. options: [ - { label: 'Draft', value: 'draft', default: true }, - { label: 'Active', value: 'active' }, - { label: 'Done', value: 'done' }, + { label: 'Draft', value: 'draft', default: true, kind: 'open' }, + { label: 'Active', value: 'active', kind: 'open' }, + { label: 'Done', value: 'done', kind: 'won' }, ], group: 'basics', }), diff --git a/packages/metadata-core/src/object-schema-fls-contract.ts b/packages/metadata-core/src/object-schema-fls-contract.ts index 2e4105ca33b..271a98b6195 100644 --- a/packages/metadata-core/src/object-schema-fls-contract.ts +++ b/packages/metadata-core/src/object-schema-fls-contract.ts @@ -132,7 +132,10 @@ export const FLS_CONTRACT_OBJECT = { salary_grade: { type: 'select', label: 'Salary Grade', - options: [{ value: 'band_a', label: 'Band A' }, { value: 'band_b', label: 'Band B' }], + // `kind` on both: the object names this field its `stageField`, and + // `ObjectSchema` requires every stage option to declare one. A salary + // band is a step on no road to an end, so both are `open`. + options: [{ value: 'band_a', label: 'Band A', kind: 'open' }, { value: 'band_b', label: 'Band B', kind: 'open' }], requiredPermissions: ['view_compensation'], }, bonus_formula: { diff --git a/packages/metadata-core/src/object-schema-fls-references.test.ts b/packages/metadata-core/src/object-schema-fls-references.test.ts index 46fb65ca5c2..20d7fa0b87f 100644 --- a/packages/metadata-core/src/object-schema-fls-references.test.ts +++ b/packages/metadata-core/src/object-schema-fls-references.test.ts @@ -707,3 +707,30 @@ describe('[ADR-0106] the shared contract table, driven through the bare projecti }); } }); + +describe('the published contract fixture meets the stage-field rule', () => { + // [#23809] `ObjectSchema` requires every option of the `stageField` field to + // declare `kind`. The fixture is not a whole spec-valid object (its `script` + // actions carry no `body` / `target` on purpose — it exercises the mask's + // reference positions, not the action contract), so this asserts the one + // rule that names its stage field: no issue at `stageField` or under + // `fields.salary_grade.options`. The positive control strips one `kind` and + // must raise exactly there. + const stageIssues = (doc: unknown) => { + const parsed = ObjectSchema.safeParse(doc); + const issues = parsed.success ? [] : parsed.error.issues; + return issues + .map((i) => i.path.join('.')) + .filter((p) => p === 'stageField' || p.startsWith('fields.salary_grade.options')); + }; + + it('`FLS_CONTRACT_OBJECT` raises no stage-field issue: both `salary_grade` options declare `kind`', () => { + expect(stageIssues(FLS_CONTRACT_OBJECT)).toEqual([]); + }); + + it('positive control: the same fixture with one option\'s `kind` removed is refused at that option', () => { + const copy = JSON.parse(JSON.stringify(FLS_CONTRACT_OBJECT)); + delete copy.fields.salary_grade.options[1].kind; + expect(stageIssues(copy)).toEqual(['fields.salary_grade.options.1.kind']); + }); +}); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 195727e98a6..3023b2ac88a 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -204,6 +204,10 @@ export const enMetadataForms: NonNullable = { "fields.options.description": { label: "Description" }, + "fields.options.kind": { + label: "Kind", + helpText: "Lifecycle category (single-value select / radio only): a step on the way, the successful end, or the unsuccessful end. Required on every option of the stage field." + }, "fields.reference": { label: "Reference", helpText: "Target object name. For a tree field it is optional and, if given, must be this object (a tree is a hierarchy within its own object — link a different object with a lookup)" @@ -678,6 +682,9 @@ export const enMetadataForms: NonNullable = { "options.visibleWhen": { label: "Visible When" }, + "options.kind": { + label: "Kind" + }, reference: { label: "Reference", helpText: "Referenced object name" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index a4b4dbcfb57..51647c6774a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -204,6 +204,10 @@ export const esESMetadataForms: NonNullable = "fields.options.description": { label: "Descripción" }, + "fields.options.kind": { + label: "Tipo", + helpText: "Categoría del ciclo de vida (solo selección / radio de un único valor): un paso intermedio, el final con éxito o el final sin éxito. Obligatoria en cada opción del campo de etapa." + }, "fields.reference": { label: "Referencia", helpText: "Nombre del objeto de destino. En un campo tree es opcional y, si se indica, debe ser este mismo objeto (un árbol es una jerarquía dentro de su propio objeto — para enlazar con otro objeto, usa un lookup)" @@ -678,6 +682,9 @@ export const esESMetadataForms: NonNullable = "options.visibleWhen": { label: "Condición de visibilidad" }, + "options.kind": { + label: "Tipo" + }, reference: { label: "Referencia", helpText: "Nombre del objeto referenciado" diff --git a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts index 07fdadda627..7d4d617c6da 100644 --- a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts @@ -673,7 +673,10 @@ describe('#19403 round 4 — the blind spot, FOURTH shape: a type-level coverage // `width`, `defaultHidden`), all twelve of their translated labels // authored by the same flight, so the loop below still reads every one of // them translated. - expect(ROW_PROPERTIES.length).toBe(10); + // + // 11 since #23809: the `options` repeater gained `kind`, the option's + // lifecycle category, its label authored in all three locales. + expect(ROW_PROPERTIES.length).toBe(11); expect(ROW_PROPERTIES).toContain('options.color'); expect(ROW_PROPERTIES).toContain('options.visibleWhen'); expect(ROW_PROPERTIES).toContain('inlineColumns.defaultHidden'); diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index b4d88e329a7..0f2732c46cb 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -204,6 +204,10 @@ export const jaJPMetadataForms: NonNullable = "fields.options.description": { label: "説明" }, + "fields.options.kind": { + label: "種別", + helpText: "ライフサイクル区分(単一値の選択 / ラジオのみ):途中の段階、成功の終点、または不成功の終点。ステージ項目のすべての選択肢で必須です。" + }, "fields.reference": { label: "参照", helpText: "対象オブジェクト名。tree フィールドでは省略可能で、指定する場合はこのオブジェクト自身でなければなりません(ツリーは同一オブジェクト内の階層です — 別のオブジェクトへリンクする場合は lookup を使います)" @@ -678,6 +682,9 @@ export const jaJPMetadataForms: NonNullable = "options.visibleWhen": { label: "表示条件" }, + "options.kind": { + label: "種別" + }, reference: { label: "参照", helpText: "参照先オブジェクト名" diff --git a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts index ff5a8b8cdfc..7eab218f78a 100644 --- a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts @@ -965,7 +965,10 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape', // 116 since #21765 gave `basics` the `imageField` row beside `nameField` — // the record-picture pointer, now live — whose label and help text are // authored in all three locales by that card, so `openEchoes` stays at zero. - expect(OPEN_LEAVES.length).toBe(116); + // 118 since #23809 gave the field rows' `options` repeater its `kind` + // sub-row (the option's lifecycle category) — label and help text, both + // authored in all three locales, so `openEchoes` stays at zero. + expect(OPEN_LEAVES.length).toBe(118); expect(PANEL_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(false); expect(OPEN_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(true); for (const path of ['name', 'label', 'fields', 'fields.valueDomain', 'fields.deleteBehavior', 'fields.expression']) { diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 6fd8fdb2560..363cfdf9ab9 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1196,7 +1196,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 672 since the field form offers the `referenceDeletedWhen` composite // beside `referenceVia`, with its `field` and `value` sub-rows — three new // row labels, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(672); + // 674 since #23809: the options repeaters gain `kind`, the option's + // lifecycle category — one row label on the field form, one on the + // object form, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(674); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 77a49aaead7..a8658397450 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -204,6 +204,10 @@ export const zhCNMetadataForms: NonNullable = "fields.options.description": { label: "描述" }, + "fields.options.kind": { + label: "类型", + helpText: "生命周期类别(仅限单值下拉 / 单选):进行中的一步、成功的终点或失败的终点。阶段字段的每个选项都必须声明。" + }, "fields.reference": { label: "引用对象", helpText: "目标对象名称。tree 字段可省略;若填写,必须是本对象自身(树是同一对象内部的层级 — 链接到其他对象请用 lookup)" @@ -678,6 +682,9 @@ export const zhCNMetadataForms: NonNullable = "options.visibleWhen": { label: "可见条件" }, + "options.kind": { + label: "类型" + }, reference: { label: "引用对象", helpText: "被引用的对象名称" diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index b9b937716c6..75aaaf9d32c 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -714,6 +714,7 @@ "SeedParsed (type)", "SeedSchema (const)", "SelectOption (type)", + "SelectOptionKind (type)", "SelectOptionParsed (type)", "SelectOptionSchema (const)", "ServiceObject (type)", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 70496f66d54..bd091ac034f 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -941,6 +941,7 @@ "data/SelectOption:color", "data/SelectOption:default", "data/SelectOption:description", + "data/SelectOption:kind", "data/SelectOption:label", "data/SelectOption:value", "data/SelectOption:visibleWhen", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index d07e6ad53c8..c2009152217 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -702,6 +702,7 @@ "SeedParsed": "src/data/seed.zod.ts#SeedParsed (type)", "SeedSchema": "src/data/seed.zod.ts#SeedSchema (const)", "SelectOption": "src/data/field.zod.ts#SelectOption (type)", + "SelectOptionKind": "src/data/field.zod.ts#SelectOptionKind (type)", "SelectOptionParsed": "src/data/field.zod.ts#SelectOptionParsed (type)", "SelectOptionSchema": "src/data/field.zod.ts#SelectOptionSchema (const)", "ServiceObject": "src/data/object.zod.ts#ServiceObject (type)", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 2fd5a9afad2..3e954ca78f2 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -54,7 +54,49 @@ }, "options": { "status": "live", - "note": "select options {label,value,description,color,default} — renderers + validation. `description` joined 2026-08-31 (objectui#6153, inheriting the objectui#6140 ruling frame): objectui LookupField searches it on authored static options (LookupField.tsx:526) and recordToOption produces it for fetched options. `icon` is NOT an option key (withdrawn from the object.form options repeater under ADR-0049, #13671; pinned by packages/spec/src/data/field-rows-option-description.test.ts). Read points moved out of spec source (#23434), measured at objectui @d8ec8d6d4f011b11c8eb1e6dbd364ef206711391 and on objectui main with the same answer: objectui @d8ec8d6d4f01 packages/fields/src/index.tsx#SelectCellRenderer (its `renderOne` badge/dot branch reads `option?.label` and `option?.color` off a `SelectOptionMetadata[]`, never `option?.icon`); no field-option render path reads `icon`; the one `opt.icon` read in that tree is the config-panel `ConfigField` vocabulary (a `React.ReactNode` an authored option cannot reach). No per-option `disabled` on the object-field pipeline (packages/spec/src/shared/editability-boundary.ts, the SelectOptionSchema prescription; moved out of spec source, #23434): measured on objectui main @aca27fa, packages/fields/src/widgets/SelectField.tsx#SingleSelectField calls the root `disabled` the single authority and packages/fields/src/widgets/RadioField.tsx#hostGroupProps reads only `props.disabled`; the per-option `disabled` that exists is the SDUI family's own vocabulary, objectui @aca27fa packages/components/src/renderers/form/select.tsx#SelectRenderer." + "note": "select options {label,value,description,color,default} — renderers + validation. `description` joined 2026-08-31 (objectui#6153, inheriting the objectui#6140 ruling frame): objectui LookupField searches it on authored static options (LookupField.tsx:526) and recordToOption produces it for fetched options. `icon` is NOT an option key (withdrawn from the object.form options repeater under ADR-0049, #13671; pinned by packages/spec/src/data/field-rows-option-description.test.ts). Read points moved out of spec source (#23434), measured at objectui @d8ec8d6d4f011b11c8eb1e6dbd364ef206711391 and on objectui main with the same answer: objectui @d8ec8d6d4f01 packages/fields/src/index.tsx#SelectCellRenderer (its `renderOne` badge/dot branch reads `option?.label` and `option?.color` off a `SelectOptionMetadata[]`, never `option?.icon`); no field-option render path reads `icon`; the one `opt.icon` read in that tree is the config-panel `ConfigField` vocabulary (a `React.ReactNode` an authored option cannot reach). No per-option `disabled` on the object-field pipeline (packages/spec/src/shared/editability-boundary.ts, the SelectOptionSchema prescription; moved out of spec source, #23434): measured on objectui main @aca27fa, packages/fields/src/widgets/SelectField.tsx#SingleSelectField calls the root `disabled` the single authority and packages/fields/src/widgets/RadioField.tsx#hostGroupProps reads only `props.disabled`; the per-option `disabled` that exists is the SDUI family's own vocabulary, objectui @aca27fa packages/components/src/renderers/form/select.tsx#SelectRenderer. #23809 (2026-10-16) DRILLED this container: `kind` joined the option shape as the one child with no reader yet, so the blanket `live` verdict could no longer cover it and the `field/options` debt row left `undrilled-containers.baseline.json`. The children carry the read points this note already cites.", + "children": { + "label": { + "status": "live", + "evidence": "objectui @63d00da: packages/fields/src/index.tsx#SelectCellRenderer", + "verifiedAt": "2026-10-16", + "note": "The badge text of a select cell (`option?.label`), and every select / radio control's item label." + }, + "value": { + "status": "live", + "evidence": "packages/objectql/src/validation/record-validator.ts#optionValues", + "verifiedAt": "2026-10-16", + "note": "The stored code: the record validator refuses a write whose value is not one of the field's option values." + }, + "description": { + "status": "live", + "evidence": "objectui @63d00da: packages/fields/src/widgets/LookupField.tsx#LookupField", + "verifiedAt": "2026-10-16", + "note": "Lookup option search matches it beside the label (objectui#6153)." + }, + "color": { + "status": "live", + "evidence": "objectui @63d00da: packages/fields/src/index.tsx#SelectCellRenderer", + "verifiedAt": "2026-10-16", + "note": "The badge / dot colour of a select cell (`option?.color`)." + }, + "default": { + "status": "live", + "evidence": "packages/objectql/src/engine.ts#resolveOptionDefault", + "verifiedAt": "2026-10-16", + "note": "The insert path falls back to the option marked `default: true` when the field declares no `defaultValue` (#7246)." + }, + "visibleWhen": { + "status": "live", + "evidence": "packages/objectql/src/validation/rule-validator.ts#evaluateOptionVisibility", + "verifiedAt": "2026-10-16", + "note": "The one visibility predicate the server also enforces: a write picking an option whose predicate is false, or cannot be evaluated, is refused (ADR-0068, ADR-0137 D2)." + }, + "kind": { + "status": "planned", + "note": "#23809 (2026-10-16): the option's lifecycle category (`open` / `won` / `lost`), declared contract-first under the maintainer's ruling on that card (「同意」). Its REFUSALS are live at parse: `FieldSchema` refuses it off a single-value select / radio, and `ObjectSchema` refuses a `stageField` whose options do not each declare it (`refuseStageFieldWithoutKinds`). No renderer reads the key yet: the readers are the objectui record path and kanban follow-up card (the path renderer already honours the same vocabulary on `record:path` `stages[].terminal`, objectui @63d00da packages/plugin-detail/src/renderers/record-path.tsx#RecordPath classify) and HotCRM's lead / opportunity declarations. No `authorWarn`: under `stageField` the key is mandatory, so a warning would fire on every compliant stage field. Flip to `live` with the objectui evidence pointer when the path renderer reads `kind` off the stage field's options." + } + } }, "picklist": { "status": "live", diff --git a/packages/spec/liveness/picklist.json b/packages/spec/liveness/picklist.json index e7764e0d08a..1ad0c9c2f2b 100644 --- a/packages/spec/liveness/picklist.json +++ b/packages/spec/liveness/picklist.json @@ -57,6 +57,10 @@ "evidence": "packages/objectql/src/validation/rule-validator.ts", "verifiedAt": "2026-10-01", "note": "Carried onto the referencing field's resolved option, where the option-visibility predicate reads it like an inline option's." + }, + "kind": { + "status": "planned", + "note": "#23809 (2026-10-16): the option shape is reused verbatim, so a shared list's option carries the lifecycle `kind` too (see `field.json`, `options.kind`). No reader yet, and no stage use today: `ObjectSchema` refuses a `stageField` naming a `picklist`-backed field, because the object cannot see the list's options to check that each declares `kind`. Widening that to judge the resolved list (where `os validate` resolves picklist references) is a later decision; until then a `kind` here parses and is read by nothing." } }, "evidence": "packages/objectql/src/registry.ts#resolvePicklistOptions" diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index b264a817cb0..48598f1aa23 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 95 | 0 | 0 | 1 | 5 | 101 | +| `field` | 100 | 0 | 0 | 1 | 6 | 107 | diff --git a/packages/spec/liveness/state-counts/picklist.md b/packages/spec/liveness/state-counts/picklist.md index cf41d72030d..f7d45d3dc6f 100644 --- a/packages/spec/liveness/state-counts/picklist.md +++ b/packages/spec/liveness/state-counts/picklist.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `picklist` | 16 | 0 | 0 | 0 | 0 | 16 | +| `picklist` | 16 | 0 | 0 | 0 | 1 | 17 | diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index f14a22f9b58..98577ec70fa 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -85,7 +85,6 @@ "field/inlineColumns.options", "field/lookupColumns", "field/lookupFilters", - "field/options", "field/storage", "field/summaryOperations", "flow/edges", diff --git a/packages/spec/src/data/field-rows-option-description.test.ts b/packages/spec/src/data/field-rows-option-description.test.ts index 5cb8175f4a3..2021818ccf6 100644 --- a/packages/spec/src/data/field-rows-option-description.test.ts +++ b/packages/spec/src/data/field-rows-option-description.test.ts @@ -299,12 +299,13 @@ describe('the object.form options repeater offers only keys the door accepts', ( expect(offeredOptionKeys()).not.toContain('icon'); }); - it('still offers the four inputs that survived, in order', () => { + it('still offers the four inputs that survived, in order, then `kind`', () => { // The positive control for the assertion above: an empty or mangled list // would satisfy `not.toContain('icon')` while having removed the whole // repeater. `description` is here because PR #13669 declared it — ⛔ do - // not drop it while editing this list. - expect(offeredOptionKeys()).toEqual(['label', 'value', 'color', 'description']); + // not drop it while editing this list. `kind` joined with #23809, the + // option's lifecycle category. + expect(offeredOptionKeys()).toEqual(['label', 'value', 'color', 'description', 'kind']); }); it('offers no input naming a key the publish door refuses — the general invariant', () => { diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 6d3c140582f..d6dfb7781ea 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -181,6 +181,7 @@ export const fieldForm = defineForm({ { field: 'color', label: 'Color' }, { field: 'default', label: 'Default' }, { field: 'visibleWhen', label: 'Visible When' }, + { field: 'kind', label: 'Kind' }, ], }, // Reference field options diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index f9095f554aa..14246dde1a4 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -416,6 +416,40 @@ export const SelectOptionSchema = lazySchema(() => strictObject({ * not record traversals and are unaffected. */ visibleWhen: EvaluatedExpressionInputSchema.optional().describe("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`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and every write picking the option would be refused, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference.").meta({ title: 'Visible When' }), + /** + * The lifecycle category of this option (#23809) — one closed vocabulary for + * every object on the platform: + * + * - `open` — a step on the way (New, Contacted, In Progress, Negotiation). + * - `won` — the SUCCESSFUL end, for every object: a deal's Closed Won, a + * lead's Converted, a task's Done, a case's Resolved. + * - `lost` — the UNSUCCESSFUL end, for every object: Closed Lost, + * Unqualified, Cancelled, Closed Unresolved. + * + * Declared once on the option and read by every renderer of the object's + * `stageField` — the record path, the kanban column order, the "closed" + * filters, the dashboard won / lost measures — instead of a renderer + * guessing a terminus from the value's spelling (`closed_won`). The spelling + * is the one the page layer already ships: `record:path` `stages[].terminal` + * (`ui/component.zod.ts`) takes `won` / `lost`, and objectui's record path + * renderer honours it ahead of its spelling heuristic — one fact, one + * vocabulary from the data layer to the page, no conversion between two. + * + * - On a plain single-choice field the key is optional, and an ABSENT kind + * reads as `open`. Nothing writes that default in at parse: absent stays + * absent, so the stage-field rule can tell "declared open" from "forgot + * the key". + * - On the field an object's `stageField` names, EVERY option must declare + * it — `ObjectSchema` refuses the object otherwise, naming the field and + * the option. No default there: a default would draw an AI-authored + * lifecycle that forgot the key as the same wrong path, only declared. + * - Only a single-value `select` / `radio` field's options take it (a + * lifecycle is one value per record); `FieldSchema` refuses it on any + * other field's options. A shared `picklist`'s options take it too — the + * same shape, reused verbatim. A form view's option list does not + * (`FormSelectOptionSchema`): the lifecycle is the object field's. + */ + kind: z.enum(['open', 'won', 'lost']).optional().describe("Lifecycle category of this option: 'open' (a step on the way), 'won' (the successful end: a deal's Closed Won, a task's Done, a case's Resolved) or 'lost' (the unsuccessful end: Closed Lost, Cancelled, Unqualified). Absent reads as 'open'. Required on every option of the field an object's `stageField` names. Only on a single-value `select` / `radio` field.").meta({ title: 'Kind' }), })); /** @@ -1974,7 +2008,12 @@ export const FieldSchema = lazySchema(() => { * (`validate-expressions.ts`: it parses, it reads `record.` and never * a bare field, and only the roots that family binds). While it holds, no * overdue wording or colour applies — "finished" stays local to the deadline - * that cares, so no select option carries a terminal marker for it. + * that cares. A select option does declare its lifecycle `kind` (`open` / + * `won` / `lost`, #23809), but overdue is still judged by `settledWhen` + * alone: nothing reads an option's `kind` to settle a deadline. An author + * who wants a closed record settled says so in the predicate + * (P`record.status in ['done', 'cancelled']`), which may later read a + * declared marker without changing this key's meaning. * * Declared = enforced (ADR-0049), both halves at this seam: either key on a * type outside the set is refused, and `settledWhen` without `dueLike: true` @@ -2437,6 +2476,31 @@ export const FieldSchema = lazySchema(() => { } } + // [#23809] An option's lifecycle `kind` says where ONE record stands in its + // life, so it is honest only on a field holding one option code per record: + // a `select` / `radio` that is not `multiple: true`. On any other field's + // options (a multi-value choice, a lookup's static options) it would parse + // and no stage renderer would ever read it (ADR-0049). One located issue per + // offending option. + if (Array.isArray(field.options)) { + const singleValueChoice = SINGLE_OPTION_TYPES.has(field.type) && field.multiple !== true; + if (!singleValueChoice) { + field.options.forEach((option, index) => { + if (option.kind === undefined) return; + ctx.addIssue({ + code: 'custom', + path: ['options', index, 'kind'], + message: + `Option '${option.value}' declares \`kind: '${option.kind}'\`, but \`kind\` is only valid on the ` + + 'options of a single-value `select` or `radio` field (this field is ' + + `\`${field.type}\`${field.multiple === true ? ' with `multiple: true`' : ''}): it says where one ` + + 'record stands in its lifecycle, and this field holds no single option code per record. Delete ' + + '`kind`, or make the field a single-value `select`.', + }); + }); + } + } + // ADR-0113: `storage.notNull` × `requiredWhen` is a contradiction, rejected // at the authoring seam — when the condition is FALSE the write contract // permits null, but the column would refuse it, so the author has declared @@ -2924,6 +2988,8 @@ export type FieldParsed = z.infer; export type SelectOption = z.input; /** Post-parse shape of {@link SelectOption} — defaults applied, transforms run (ADR-0122). */ export type SelectOptionParsed = z.infer; +/** The lifecycle category a select option declares (`SelectOption.kind`, #23809). */ +export type SelectOptionKind = NonNullable; /** One authored `inlineColumns` entry (#9227) — the strict, name-keyed inline grid column. */ export type InlineGridColumn = z.input; /** Post-parse shape of {@link InlineGridColumn} — bare-string CEL predicates normalized to Expression envelopes (ADR-0122). */ diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 84f76253481..2d4b78c7a90 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -286,6 +286,20 @@ export const objectForm = defineForm({ { field: 'value', label: 'Value', type: 'text', required: true }, { field: 'color', label: 'Color', type: 'color' }, { field: 'description', label: 'Description', type: 'text' }, + // #23809 — the option's lifecycle category. Required on every + // option of the object's `stageField`; valid only on a + // single-value `select` / `radio`. + { + field: 'kind', + label: 'Kind', + type: 'select', + helpText: 'Lifecycle category (single-value select / radio only): a step on the way, the successful end, or the unsuccessful end. Required on every option of the stage field.', + options: [ + { label: 'Open', value: 'open' }, + { label: 'Won', value: 'won' }, + { label: 'Lost', value: 'lost' }, + ], + }, ], }, diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index c7916c270c2..60d11818410 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -18,6 +18,9 @@ import { retiredKey } from '../shared/retired-key'; import { refuseRecordProtoKey } from '../shared/record-proto-key-guard'; import { bannedKeys } from '../shared/refinement-projection'; import { FIELD_GROUP_KEY_PATTERN } from './field-group-layout'; +// [#23809] The single-option-code types — the only ones whose options take a +// lifecycle `kind`, and so the only option fields a `stageField` may name. +import { SINGLE_OPTION_TYPES } from './field-value.zod'; // The masked-on-read declaration (#20141); `warnGenericPasswordFields` reads its // `password` exemption rather than restating it. No runtime cycle: that module's // only imports are type-only. @@ -1933,6 +1936,104 @@ function refuseDishonestReferenceDeletedWhen( } } +/** + * [#23809, maintainer ruling on the card: 「同意」] The field an object's + * `stageField` names declares a `kind` on EVERY option — `open`, `won` or + * `lost` (`SelectOption.kind`). Every renderer of the stage field (the + * record path, the kanban column order, the "closed" filters, the won / lost + * measures) reads that kind to tell the steps from the two ends, so an option + * without one would be drawn by the old guess from its spelling — the defect + * this key exists to end. There is deliberately no default here: on a plain + * select an absent kind reads as `open`, but under `stageField` that reading + * would hand an AI that forgot the key the same wrong path, only declared. + * + * Refused, judged against the AUTHORED field map: + * + * - one located issue per option without `kind`, at + * `['fields', , 'options', , 'kind']`, naming the field and the + * option; + * - at `['stageField']`, a stage field whose options are not declared on this + * object (a `picklist`-backed select): its options cannot be judged here, + * and this is the one door every save passes, so it is refused rather than + * let through unjudged; + * - at `['stageField']`, a stage field that carries options but holds no + * single option code per record (a multi-value choice, a `multiple: true` + * select): a record stands at one stage. + * + * Unchanged, on purpose: `stageField: false`, a `stageField` naming no declared + * field (the lint `semantic-roles` rule reports it), and one naming a field + * with no options at all. + * + * Judged at parse, beside {@link refuseDishonestReferenceDeletedWhen}, so every + * door — `defineStack`, a raw `.parse()`, the metadata save door — refuses it. + */ +function refuseStageFieldWithoutKinds( + ownName: unknown, + stageField: unknown, + fields: unknown, + ctx: z.RefinementCtx, +): void { + if (typeof stageField !== 'string') return; + if (fields === null || typeof fields !== 'object') return; + const fieldMap = fields as Record; + if (!Object.prototype.hasOwnProperty.call(fieldMap, stageField)) return; + const def = fieldMap[stageField] as + | { type?: unknown; multiple?: unknown; options?: unknown; picklist?: unknown } + | null + | undefined; + if (def === null || typeof def !== 'object') return; + const objectName = typeof ownName === 'string' ? ownName : ''; + const type = typeof def.type === 'string' ? def.type : String(def.type); + const hasOptionSource = Array.isArray(def.options) || typeof def.picklist === 'string'; + const prescription = + 'A stage field is a single-value `select` (or `radio`) whose inline `options` each declare `kind`: ' + + "`'open'` for a step on the way, `'won'` for the successful end, `'lost'` for the unsuccessful end " + + "(e.g. `{ label: 'Closed Won', value: 'closed_won', kind: 'won' }`). Or set `stageField: false` if " + + 'this object has no linear lifecycle.'; + + if (!SINGLE_OPTION_TYPES.has(type) || def.multiple === true) { + if (!hasOptionSource) return; + ctx.addIssue({ + code: 'custom', + path: ['stageField'], + message: + `Object \`${objectName}\` declares \`stageField: '${stageField}'\`, a \`${type}\` field` + + `${def.multiple === true ? ' with `multiple: true`' : ''} that holds no single option code per ` + + `record, so it cannot be a lifecycle stage. ${prescription}`, + }); + return; + } + + if (!Array.isArray(def.options)) { + if (typeof def.picklist !== 'string') return; + ctx.addIssue({ + code: 'custom', + path: ['stageField'], + message: + `Object \`${objectName}\` declares \`stageField: '${stageField}'\`, a field that takes its options from ` + + `\`picklist: '${def.picklist}'\`. Those options are not declared on this object, so whether each ` + + `declares its lifecycle \`kind\` cannot be checked here. Declare the stage field's options inline on ` + + `\`${stageField}\` instead of the picklist. ${prescription}`, + }); + return; + } + + def.options.forEach((option, index) => { + const rec = option as { value?: unknown; kind?: unknown } | null; + if (rec === null || typeof rec !== 'object' || rec.kind !== undefined) return; + const value = typeof rec.value === 'string' ? rec.value : String(rec.value); + ctx.addIssue({ + code: 'custom', + path: ['fields', stageField, 'options', index, 'kind'], + message: + `Option '${value}' of field \`${stageField}\` declares no \`kind\`, and object \`${objectName}\` names ` + + `\`${stageField}\` as its \`stageField\`: every option of a stage field declares its lifecycle kind, ` + + `so the record path can tell the steps from the two ends. Add \`kind: 'open'\` (a step on the way), ` + + `\`kind: 'won'\` (the successful end) or \`kind: 'lost'\` (the unsuccessful end) to '${value}'.`, + }); + }); +} + /** * The machine-name grammar an attached block and its leaves share with field * names — they are keys on the same served row (`record..`). @@ -2783,8 +2884,17 @@ const ObjectSchemaBase = strictObject( * a stage field (status/stage/state/phase). Consumed by the record-detail * path/stepper today; kanban default grouping, list badges and report * bucketing are natural future consumers. + * + * [#23809] When the named field carries options, it must be a single-value + * `select` / `radio` whose inline options EACH declare their lifecycle `kind` + * (`open` / `won` / `lost`), so those consumers tell the steps from the two + * ends by declaration, not by the value's spelling. The parse refuses such a + * field with an option lacking `kind`, a `picklist`-backed one, and a + * multi-value one (see `refuseStageFieldWithoutKinds`). Not judged here: + * `false`, a name that resolves to no declared field (the lint + * `semantic-roles` rule reports it), and a field with no options at all. */ - stageField: z.union([z.string(), z.literal(false)]).optional().describe('[ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed.'), + stageField: z.union([z.string(), z.literal(false)]).optional().describe("[ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse."), /** * Cross-renderer EDIT-INTERACTION intent: whether a record of this object is @@ -3044,6 +3154,10 @@ const ObjectSchemaBase = strictObject( // predicate names a declared single-value `select` sibling and one of its // options — the same door: only the object holds the sibling map. refuseDishonestReferenceDeletedWhen(object.name, object.fields, ctx); + // [#23809] Every option of the field `stageField` names declares its + // lifecycle `kind` — the same door: only the object knows which field is + // its stage. + refuseStageFieldWithoutKinds(object.name, object.stageField, object.fields, ctx); // [#22211 ruling A] A read attachment names at least one leaf, and is not a // field: its block names may not repeat a declared field name. refuseAttachedBlockConflicts(object.attachedOnRead, object.fields, ctx); diff --git a/packages/spec/src/data/select-option-kind.test.ts b/packages/spec/src/data/select-option-kind.test.ts new file mode 100644 index 00000000000..bf58660a5fe --- /dev/null +++ b/packages/spec/src/data/select-option-kind.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { FieldSchema, SelectOptionSchema, type SelectOptionKind } from './field.zod'; +import { ObjectSchema } from './object.zod'; +import { PicklistSchema } from './picklist.zod'; + +// --------------------------------------------------------------------------- +// [#23809] A select option declares its lifecycle `kind` — `open` (a step on +// the way), `won` (the successful end), `lost` (the unsuccessful end): +// +// - option half (`SelectOptionSchema`): the closed vocabulary; absent stays +// absent (reads as `open`, never written in at parse); +// - field half (`FieldSchema`): `kind` only on a single-value `select` / +// `radio` field's options; +// - object half (`ObjectSchema`): every option of the field `stageField` +// names declares `kind`, refused per option naming the field and option. +// +// Each refusal is ONE located `custom` issue; the pins assert its path and the +// field and option names an author greps for, never the prose around them. +// --------------------------------------------------------------------------- + +const KINDED_STAGES = [ + { label: 'New', value: 'new', kind: 'open' }, + { label: 'Qualified', value: 'qualified', kind: 'open' }, + { label: 'Converted', value: 'converted', kind: 'won' }, + { label: 'Unqualified', value: 'unqualified', kind: 'lost' }, +]; + +const lead = (status: Record, extra: Record = {}) => ({ + name: 'lead', + fields: { + name: { type: 'text', label: 'Name' }, + status: { label: 'Status', ...status }, + }, + stageField: 'status', + ...extra, +}); + +function issuesOf(result: { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; message: string; code: string }> } }) { + expect(result.success).toBe(false); + return result.error!.issues; +} + +describe('SelectOption.kind — the closed lifecycle vocabulary', () => { + it('is exactly open | won | lost', () => { + const kinds: SelectOptionKind[] = ['open', 'won', 'lost']; + expect(SelectOptionSchema.shape.kind.unwrap().options).toEqual(kinds); + }); + + it('an option with a terminal kind parses, and keeps it', () => { + expect(SelectOptionSchema.parse({ label: 'Closed Won', value: 'closed_won', kind: 'won' }).kind).toBe('won'); + expect(SelectOptionSchema.parse({ label: 'Closed Lost', value: 'closed_lost', kind: 'lost' }).kind).toBe('lost'); + const field = FieldSchema.parse({ type: 'select', label: 'Stage', options: KINDED_STAGES }); + expect(field.options?.map((o) => o.kind)).toEqual(['open', 'open', 'won', 'lost']); + }); + + it('an unknown kind is refused at the option, located', () => { + for (const kind of ['done', 'cancelled', 'closed', 'WON', '']) { + const issues = issuesOf(FieldSchema.safeParse({ + type: 'select', + label: 'Stage', + options: [{ label: 'Done', value: 'done', kind }], + })); + expect(issues.map((i) => i.path.join('.'))).toContain('options.0.kind'); + } + }); + + it('a plain select without kinds parses, and an absent kind stays absent (reads as open)', () => { + const field = FieldSchema.parse({ + type: 'select', + label: 'Priority', + options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }], + }); + expect(field.options?.every((o) => o.kind === undefined)).toBe(true); + expect(field.options?.every((o) => !Object.prototype.hasOwnProperty.call(o, 'kind'))).toBe(true); + // The same plain select on an object that names no stage field. + expect(ObjectSchema.safeParse({ + name: 'ticket', + fields: { priority: { type: 'select', label: 'Priority', options: [{ label: 'Low', value: 'low' }] } }, + }).success).toBe(true); + }); + + it('a shared picklist reuses the option shape, kind included', () => { + const parsed = PicklistSchema.parse({ name: 'case_status', label: 'Case Status', options: [ + { label: 'Open', value: 'open', kind: 'open' }, + { label: 'Resolved', value: 'resolved', kind: 'won' }, + ] }); + expect(parsed.options.map((o) => o.kind)).toEqual(['open', 'won']); + }); +}); + +describe('SelectOption.kind on a field that is not a single-value select', () => { + const kinded = [{ label: 'A', value: 'a', kind: 'open' }, { label: 'B', value: 'b' }]; + + it.each([ + ['multiselect', {}], + ['checkboxes', {}], + ['tags', {}], + ['select', { multiple: true }], + ['radio', { multiple: true }], + ['lookup', { reference: 'account' }], + ['text', {}], + ])('is refused on a `%s` field %j, at the option that carries it', (type, extra) => { + const issues = issuesOf(FieldSchema.safeParse({ type, label: 'X', options: kinded, ...extra })); + const kindIssues = issues.filter((i) => i.path.join('.') === 'options.0.kind'); + expect(kindIssues).toHaveLength(1); + expect(kindIssues[0].code).toBe('custom'); + expect(kindIssues[0].message).toContain("Option 'a'"); + expect(kindIssues[0].message).toContain(`\`${type}\``); + // The option WITHOUT kind is not flagged. + expect(issues.some((i) => i.path.join('.') === 'options.1.kind')).toBe(false); + }); + + it('is accepted on a single-value `radio`', () => { + expect(FieldSchema.safeParse({ type: 'radio', label: 'X', options: KINDED_STAGES }).success).toBe(true); + }); +}); + +describe('ObjectSchema — every option of the stageField declares kind', () => { + it('a stage field whose every option declares kind parses', () => { + const parsed = ObjectSchema.parse(lead({ type: 'select', options: KINDED_STAGES })); + expect(parsed.stageField).toBe('status'); + expect((parsed.fields.status as { options: Array<{ kind?: string }> }).options.map((o) => o.kind)) + .toEqual(['open', 'open', 'won', 'lost']); + }); + + it('one option lacking kind is refused at publish, naming the field and the option', () => { + const options = KINDED_STAGES.map((o) => (o.value === 'unqualified' ? { label: o.label, value: o.value } : o)); + const issues = issuesOf(ObjectSchema.safeParse(lead({ type: 'select', options }))); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('custom'); + expect(issues[0].path).toEqual(['fields', 'status', 'options', 3, 'kind']); + expect(issues[0].message).toContain("Option 'unqualified'"); + expect(issues[0].message).toContain('field `status`'); + }); + + it('each option lacking kind is its own located issue (no default fills them)', () => { + const options = [{ label: 'New', value: 'new' }, { label: 'Done', value: 'done', kind: 'won' }, { label: 'Gone', value: 'gone' }]; + const issues = issuesOf(ObjectSchema.safeParse(lead({ type: 'radio', options }))); + expect(issues.map((i) => i.path.join('.'))).toEqual(['fields.status.options.0.kind', 'fields.status.options.2.kind']); + }); + + it('a picklist-backed stage field is refused at stageField: its options cannot be judged here', () => { + const issues = issuesOf(ObjectSchema.safeParse(lead({ type: 'select', picklist: 'lead_status' }))); + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['stageField']); + expect(issues[0].message).toContain("picklist: 'lead_status'"); + }); + + it.each([ + ['multiselect', {}], + ['select', { multiple: true }], + ])('a `%s` %j stage field carrying options is refused at stageField', (type, extra) => { + const options = [{ label: 'A', value: 'a' }]; + const issues = issuesOf(ObjectSchema.safeParse(lead({ type, options, ...extra }))); + expect(issues.filter((i) => i.path.join('.') === 'stageField')).toHaveLength(1); + }); + + it('leaves the existing postures alone: false, a missing field, a field with no options', () => { + const plain = { type: 'select', options: [{ label: 'Active', value: 'active' }] }; + expect(ObjectSchema.safeParse(lead(plain, { stageField: false })).success).toBe(true); + expect(ObjectSchema.safeParse(lead(plain, { stageField: 'nope' })).success).toBe(true); + expect(ObjectSchema.safeParse(lead({ type: 'text' })).success).toBe(true); + expect(ObjectSchema.safeParse(lead(plain, { stageField: undefined })).success).toBe(true); + }); + + it('create() refuses the same object (the authoring door)', () => { + expect(() => ObjectSchema.create(lead({ + type: 'select', + options: [{ label: 'New', value: 'new' }], + }) as unknown as Parameters[0])).toThrow(/Option 'new'/); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.stage-field-option-kind-required.ts b/packages/spec/src/migrations/entries/semantic/18.stage-field-option-kind-required.ts new file mode 100644 index 00000000000..d8788c2bf11 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.stage-field-option-kind-required.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #23809 — the select option's lifecycle `kind`, mandatory under `stageField`. +// A D3 entry with no conversion on purpose: the maintainer's ruling withholds a +// default under `stageField`, so no mechanical edit can say which option ends a +// record's life successfully and which does not. This entry states the +// refusal an authored source meets and the edit only its author can make. +export const entry: SemanticMigration = { + id: 'stage-field-option-kind-required', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: 'object.stageField naming a select whose options declare no kind (data/object.zod.ts)', + replacement: + "`kind: 'open' | 'won' | 'lost'` on every option of the field `stageField` names, for example " + + "`{ label: 'Closed Won', value: 'closed_won', kind: 'won' }`", + reason: + 'A select option now declares its lifecycle category, `kind`: `open` for a step on the way, `won` ' + + 'for the successful end, `lost` for the unsuccessful end. The record path and the other stage ' + + 'renderers read it instead of guessing a terminus from the value\'s spelling, which drew a lost ' + + 'state such as Unqualified as one more step after Converted. On a plain select the key is optional ' + + 'and an absent kind reads as `open`. On the field an object\'s `stageField` names, every option must ' + + 'declare it: a maintainer ruling withholds a default there, because a default would draw a lifecycle ' + + 'whose author forgot the key as the same wrong path. So the object parse refuses, at ' + + '`fields..options..kind`, every option of the stage field that declares no kind, naming ' + + 'the field and the option; and at `stageField` it refuses a stage field whose options are not ' + + 'declared on the object (a picklist-backed select) or that holds no single option code per record ' + + '(a multi-value choice). The refusal reaches `defineStack`, `objectstack validate`, ' + + '`objectstack build` and the metadata save door. No conversion makes the edit: which option is the ' + + 'successful end and which the unsuccessful one is the author\'s to say. Add `kind` to each option ' + + 'of the stage field, or set `stageField: false` if the object has no linear lifecycle.', + acceptanceCriteria: + '`objectstack validate` reports no refusal at a `fields..options..kind` or `stageField` path; ' + + 'every option of each object\'s stage field declares `kind`, with each terminal state marked `won` ' + + 'or `lost` and every other state `open`.', +}; diff --git a/packages/spec/src/ui/form-select-option.test.ts b/packages/spec/src/ui/form-select-option.test.ts index 071987c6b4a..7dc43ef3c30 100644 --- a/packages/spec/src/ui/form-select-option.test.ts +++ b/packages/spec/src/ui/form-select-option.test.ts @@ -167,11 +167,18 @@ describe('the object-field face is untouched', () => { // =========================================================================== describe('FormSelectOptionSchema is an Omit of SelectOptionSchema', () => { - it('shape = the object face minus exactly `default`', () => { + it('shape = the object face minus exactly `default` and `kind` (#23809)', () => { 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')); + expect(objectKeys).toContain('kind'); + expect(formKeys).toEqual(objectKeys.filter((k) => k !== 'default' && k !== 'kind')); + }); + + it('`kind` on a form-view option is refused with a prescription pointing at the object field (#23809)', () => { + const m = unknownKeyMessage(FormSelectOptionSchema, { ...OPTION, kind: 'won' }); + expect(m).toContain('kind'); + expect(m).toContain('OBJECT field'); }); it('property schemas are shared BY REFERENCE — an Omit, not a copy', () => { @@ -201,8 +208,9 @@ describe('FormSelectOptionSchema is an Omit of SelectOptionSchema', () => { ); 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']); + // The removed key and its two spellings are all answered by guidance, and + // so is `kind`, the object field's lifecycle category (#23809). + expect(Object.keys(formDecl!.options.guidance ?? {}).sort()).toEqual(['default', 'isDefault', 'kind', 'selected']); }); it('the editability boundary rides along (one vocabulary, per-shape answer)', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 6247a6df825..ad9899323b9 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3154,7 +3154,8 @@ export const ListViewSchema = lazySchema(() => * 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 + * `default` and — since #23809 — `kind`, the object field's lifecycle category, + * which no form-view reader has. 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 @@ -3173,7 +3174,11 @@ export const FormSelectOptionSchema = lazySchema(() => { // 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; + // [#23809] `kind` (the option's lifecycle category) is object-face only as + // well: the stage renderers read it off the OBJECT field's options, and a + // form view's option list relabels or subsets that field without owning its + // lifecycle. It never entered this face, so the guidance below answers it. + const { default: _objectFaceOnly, kind: _objectLifecycleOnly, ...formOptionShape } = SelectOptionSchema.shape; return strictObject({ surface: 'this form-view select option', history: @@ -3205,14 +3210,18 @@ export const FormSelectOptionSchema = lazySchema(() => { + '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).', + kind: + '`kind` is not a form-view option key: an option\'s lifecycle category (`open` / `won` / ' + + '`lost`) belongs to the OBJECT field, where the record path and the other stage renderers ' + + 'read it. Delete it here and declare `kind` 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).", + 'Form-view select option — the object-field option shape minus the per-option `default` and ' + + '`kind` keys (declare the pre-selected choice on the object definition: field-level ' + + "`defaultValue`, or `default: true` on that field's own `options` entry; declare `kind` there too).", ); });