diff --git a/.changeset/19518-picklist-kind.md b/.changeset/19518-picklist-kind.md new file mode 100644 index 00000000000..01dae8d8f13 --- /dev/null +++ b/.changeset/19518-picklist-kind.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': patch +'@objectstack/cli': patch +'@objectstack/driver-sql': patch +--- + +feat(spec): the `picklist` metadata kind — a shared option list that select fields reference by name (#19518) + +Clause-②: yes (widening) + +- **The kind.** `PicklistSchema` — `{ name, label, description?, options }`, where `options` is the field option shape (`SelectOptionSchema`) reused as is. Authored in a package as `*.picklist.ts` (`definePicklist`) or `defineStack({ picklists })`. It is a registered kind (`MetadataTypeSchema`, `DEFAULT_METADATA_TYPE_REGISTRY`, `getMetadataTypeSchema('picklist')`) that loads before `object`. It is package-owned, so a runtime create or a per-organization overlay is refused. +- **The reference.** `Field.select({ picklist: 'industry' })` adds a `picklist` key to `FieldSchema`. It is valid on the option types only (select, radio, multiselect, checkboxes, tags). A field that declares both `picklist` and `options` is refused at `options`, with a prescription. The functional-completeness predicate counts a `picklist` reference as the field's option source. +- **The served shape.** `PicklistServedFieldSchema` declares what a client reads for a picklist-bound field: the resolved `options` next to the `picklist` that names the list. This release does not resolve the reference. Until the runtime does, a picklist-bound field is served without options, and the liveness ledger grades the key `planned` and warns an author who writes it. +- **Extensions.** `defineStack({ picklistExtensions: [{ extend, options }] })` adds options to a picklist that another package owns. It can only add; removing or renaming a value stays with the owning package. +- **Translation.** `TranslationData` gains `picklists..{ label?, options: { value: label } }`. `translatePicklist` translates a served picklist item. `translateObject` gives a picklist-bound field the list's option labels, and a field-level `options` entry still wins over them. +- **Studio type label.** `@objectstack/platform-objects` carries the `picklist` type's label and description in its metadata-forms translation bundles (en, zh-CN, ja-JP, es-ES). +- **Extraction.** `os i18n extract` walks `picklists.NAME.{label, options.VALUE}`, including an extension's options under the list it extends, and `os lint` reports an untranslated option under its own rule, `i18n/missing-picklist`. +- **SQL driver.** The SQL driver classifies the `picklist` field key as presentation, so it adds no column. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index b1c83195238..404ea75dd97 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -115,7 +115,7 @@ In shared-database multi-tenancy, **most metadata types must not be per-org cust | `datasource` | ❌ | Connection strings; multi-tenant isolation is enforced at a higher layer. (`allowRuntimeCreate: true` — the datasource wizard persists `origin: 'runtime'` rows.) | | `job` | ❌ | **Also `allowRuntimeCreate: false` since protocol 17** (#4509). `JobSchema.handler` names a function in the compiled bundle's function table, which a runtime writer has no way to reach — so a job created in Studio or through `PUT /meta` parsed, saved, reported success and was never scheduled. The door is closed rather than bridged: `job` stays first-class through `*.job.ts` / `defineStack({ jobs, functions })`, where every schedule shape, `retryPolicy` and `timeout` does reach the scheduler. Existing rows are untouched — they were never scheduled — and `migrateStoredMetadata` reports them `skipped`. | -Those five are the **complete** `allowOrgOverride: true` set: of the 27 types in `DEFAULT_METADATA_TYPE_REGISTRY`, every other one is `false`. The ❌ rows above are the `false` types whose *second* tier (`allowRuntimeCreate`) is worth calling out; any type not listed is `allowOrgOverride: false`. +Those five are the **complete** `allowOrgOverride: true` set: of the 28 types in `DEFAULT_METADATA_TYPE_REGISTRY`, every other one is `false`. The ❌ rows above are the `false` types whose *second* tier (`allowRuntimeCreate`) is worth calling out; any type not listed is `allowOrgOverride: false`. There is no `workflow` metadata type (per [ADR-0020](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0020-state-machine-converge-and-enforce.md), record state machines are a `state_machine` validation). Nor is there a standalone `validation` type any more — it was retired in protocol 17 under [ADR-0088](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0088-metadata-kind-admission-and-retirement.md) because `ValidationRuleSchema` carries no object-binding key, so a rule authored through that door could never say what it protected; author rules in the object's own `validations[]` instead. The runtime gate is implemented in `OVERLAY_ALLOWED_TYPES` (derived from the registry) and enforced by `SysMetadataRepository.put()`. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index be80856bddc..c3f2179f90f 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -22,7 +22,7 @@ Categories that have no section here at all are named under [Categories Without a Section](#categories-without-a-section) — that curation is stated, not left implicit. -## Data Protocol (16 of 29 schemas) +## Data Protocol (16 of 30 schemas) Core business logic and data modeling schemas. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index b43699d486e..989028c19a7 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -648,7 +648,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -715,7 +715,7 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| … +12 more>` | ✅ | Metadata type | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +13 more>` | ✅ | Metadata type | | **name** | `string` | ✅ | Item name (snake_case) | | **data** | `Record` | ✅ | Metadata payload | | **namespace** | `string` | optional | Optional namespace | @@ -727,6 +727,7 @@ Metadata query with filtering, sorting, and pagination * `hook` * `seed` * `mapping` +* `picklist` * `view` * `page` * `dashboard` diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index e64a6d72ed2..135ca190284 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -110,8 +110,10 @@ Installed package row whose manifest is the assembled package body | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | | **functions** | `Record }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries | | **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects | -| **translations** | `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>[]` | optional | I18n Translation Bundles | +| **translations** | `Record; picklists?: Record; apps?: Record; messages?: Record; … }>[]` | optional | I18n Translation Bundles | | **objectExtensions** | `{ extend: string; fields?: Record; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages | +| **picklists** | `{ name: string; label: string; description?: string; options: object[]; … }[]` | optional | Shared option lists that select fields reference by name | +| **picklistExtensions** | `{ extend: string; options: object[] }[]` | optional | Options added to picklists owned by other packages (additive only) | | **apps** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Applications | | **views** | `{ name?: string; label?: string \| Record; object?: string; list?: object; … }[]` | optional | List Views | | **viewItems** | `never` | optional | [MACHINE-ASSEMBLED] Non-container view artifacts of a runtime-assembled manifest (standalone ViewItems, flattened overlays) — written by package export and artifact factories, refused in authored stack sources. | @@ -352,8 +354,10 @@ Installed package row whose manifest is the assembled package body | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | | **functions** | `Record }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries | | **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects | -| **translations** | `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>[]` | optional | I18n Translation Bundles | +| **translations** | `Record; picklists?: Record; apps?: Record; messages?: Record; … }>[]` | optional | I18n Translation Bundles | | **objectExtensions** | `{ extend: string; fields?: Record; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages | +| **picklists** | `{ name: string; label: string; description?: string; options: object[]; … }[]` | optional | Shared option lists that select fields reference by name | +| **picklistExtensions** | `{ extend: string; options: object[] }[]` | optional | Options added to picklists owned by other packages (additive only) | | **apps** | `{ name: string; label: string \| Record; description?: string \| Record; icon?: string; … }[]` | optional | Applications | | **views** | `{ name?: string; label?: string \| Record; object?: string; list?: object; … }[]` | optional | List Views | | **viewItems** | `never` | optional | [MACHINE-ASSEMBLED] Non-container view artifacts of a runtime-assembled manifest (standalone ViewItems, flattened overlays) — written by package export and artifact factories, refused in authored stack sources. | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index d5bc433fec7..5659f9bb66b 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1602,13 +1602,14 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **locale** | `string` | ✅ | Locale code | -| **translations** | `{ objects?: Record; apps?: Record; messages?: Record; globalActions?: Record; … }` | ✅ | Translation data | +| **translations** | `{ objects?: Record; picklists?: Record; apps?: Record; messages?: Record; … }` | ✅ | Translation data | ### Nested Shape: `GetTranslationsResponse.translations` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objects** | `Record; … }>` | optional | Object translations keyed by object name | +| **picklists** | `Record }>` | optional | Picklist translations keyed by picklist name | | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | | **globalActions** | `Record` | optional | Global action translations keyed by action name | diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 7bf37a00043..9f3a3af0d9a 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -75,6 +75,7 @@ const result = CurrencyConfigSchema.parse(data); | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect | +| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). The server resolves the reference: the field clients read carries the resolved `options`. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | diff --git a/content/docs/references/data/index.mdx b/content/docs/references/data/index.mdx index 543ec10bbdf..2388cce913f 100644 --- a/content/docs/references/data/index.mdx +++ b/content/docs/references/data/index.mdx @@ -1,7 +1,7 @@ --- title: Data Protocol — complete schema reference navTitle: Data Protocol -description: "The ObjectStack Data Protocol in 29 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack Data Protocol in 30 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -34,6 +34,7 @@ This section contains all protocol schemas for the data layer of ObjectStack. + diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json index ec5a53503d9..2d33c427dcc 100644 --- a/content/docs/references/data/meta.json +++ b/content/docs/references/data/meta.json @@ -34,6 +34,7 @@ "driver-postgres", "driver-sqlite", "driver-turso", - "field-value" + "field-value", + "picklist" ] } \ No newline at end of file diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 8c7f36be7ce..d9927849adc 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -238,6 +238,7 @@ const result = ApiMethod.parse(data); | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect | +| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). The server resolves the reference: the field clients read carries the resolved `options`. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | @@ -570,6 +571,7 @@ const result = ApiMethod.parse(data); | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect | +| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). The server resolves the reference: the field clients read carries the resolved `options`. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | diff --git a/content/docs/references/data/picklist.mdx b/content/docs/references/data/picklist.mdx new file mode 100644 index 00000000000..14335c682ee --- /dev/null +++ b/content/docs/references/data/picklist.mdx @@ -0,0 +1,135 @@ +--- +title: Picklist schema — Data Protocol reference +navTitle: Picklist +description: "One list of select options that several fields on several objects use, instead of an options array copied into each field." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +One list of select options that several fields on several objects use, +instead of an options array copied into each field (Salesforce's Global +Value Set, Dataverse's global choice). A field REFERENCES it with +`picklist: ''` in place of `options` — `Field.select({ picklist: +'industry' })` — and the two are mutually exclusive at the field's schema +door (see `FieldSchema.picklist`). + +Three shapes live here, one per position the list appears in: + +- `PicklistSchema` — the kind itself: `{ name, label, description?, + options }`, authored in a package (`*.picklist.ts`, or + `defineStack({ picklists })`). `options` is the field option shape + (`SelectOptionSchema`), reused verbatim — there is no second option + shape to learn. +- `PicklistExtensionSchema` — `defineStack({ picklistExtensions })`, + the `objectExtensions` idiom: another package ADDS options to a picklist + it does not own. Additive only — removing or renaming a value stays with + the owning package, so the shape has no key for either. +- `PicklistServedFieldSchema` — the served form of a picklist-bound + field: what a client reads from the object read exits once the reference + is resolved. + +Package-owned: the registry entry (`kernel/metadata-plugin.zod.ts`) takes +no runtime create and no per-organization overlay. An organization-level +overlay that appends values is a later phase with its own admission, and +is not declared here. + + +**Source:** `packages/spec/src/data/picklist.zod.ts` + + +## TypeScript Usage + +```typescript +import { PicklistSchema, PicklistExtensionSchema, PicklistServedFieldSchema } from '@objectstack/spec/data'; +import type { Picklist, PicklistExtension, PicklistServedField } from '@objectstack/spec/data'; + +// Validate data +const result = PicklistSchema.parse(data); +``` + +--- + +## Picklist + +A shared option list that select fields reference by name instead of copying options + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Picklist name (lowercase snake_case) — what a field's `picklist` names | +| **label** | `string` | ✅ | Display label of the list itself | +| **description** | `string` | optional | What the list enumerates, for authors choosing one | +| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | ✅ | The options every referencing field offers — the field option shape (`label`, `value`, `color`, `default`, `description`, `visibleWhen`), reused verbatim | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | + +### Nested Shape: `Picklist.options[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | ✅ | Display label (human-readable, any case allowed) | +| **value** | `string` | ✅ | Stored value (lowercase machine identifier) | +| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. | +| **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 — 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 be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | + + +--- + +## PicklistExtension + +Options added to a picklist owned by another package (additive only — the `objectExtensions` idiom) + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **extend** | `string` | ✅ | Name of the picklist (owned by another package) to add options to | +| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | ✅ | Options appended to the target picklist (additive only) | + +### Nested Shape: `PicklistExtension.options[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | ✅ | Display label (human-readable, any case allowed) | +| **value** | `string` | ✅ | Stored value (lowercase machine identifier) | +| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. | +| **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 — 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 be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | + + +--- + +## PicklistServedField + +The served form of a picklist-bound field: `options` resolved, `picklist` kept + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **picklist** | `string` | ✅ | The picklist the field references, as authored | +| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | ✅ | The picklist's options resolved onto the field (with its extensions' options) | + +### Nested Shape: `PicklistServedField.options[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | ✅ | Display label (human-readable, any case allowed) | +| **value** | `string` | ✅ | Stored value (lowercase machine identifier) | +| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. | +| **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 — 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 be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index eb443f09f76..77bade4a181 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1521 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1524 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/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 429 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 75 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | -| [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 30 | 178 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 16 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 30 | 157 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 165 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1521** | 14 protocol modules | +| **Total** | **197** | **1524** | 14 protocol modules | --- @@ -131,7 +131,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 175 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **30 pages, 178 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -162,6 +162,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`hook-body.zod.ts`](/docs/references/data/hook-body) | `ExpressionBody`, `HookBody`, `HookBodyCapability`, `ScriptBody` | | [`mapping.zod.ts`](/docs/references/data/mapping) | `ImportFieldMapping`, `Mapping`, `TransformType` | | [`object.zod.ts`](/docs/references/data/object) | `ApiMethod`, `ApiOperation`, `Index`, `Lifecycle`, `LifecycleClass`, `Object`, `ObjectAccessConfig`, `ObjectCapabilities`, `ObjectExtension`, `ObjectExternalBinding`, `ObjectFieldGroup`, `ObjectOwnershipEnum`, `ObjectRequiredPermissions`, `PerOperationRequiredPermissions`, `RowCrudActionOverride`, `TenancyConfig` | +| [`picklist.zod.ts`](/docs/references/data/picklist) | `Picklist`, `PicklistExtension`, `PicklistServedField` | | [`query.zod.ts`](/docs/references/data/query) | `AggregationFunction`, `AggregationNode`, `DateGranularity`, `FieldNode`, `FullTextSearch`, `GroupByNode`, `Query`, `SortNode` | | [`seed.zod.ts`](/docs/references/data/seed) | `Seed`, `SeedMode` | | [`seed-loader.zod.ts`](/docs/references/data/seed-loader) | `ObjectDependencyGraph`, `ObjectDependencyNode`, `ReferenceResolution`, `ReferenceResolutionError`, `SeedIdentity`, `SeedLoadResult`, `SeedLoaderConfig`, `SeedLoaderRequest`, `SeedLoaderResult` | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 6167587d71a..b169b05afb8 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -179,7 +179,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | +| **types** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'api' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'position' \| 'capability' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types | | **namespaces** | `string[]` | optional | Filter by namespaces | | **packageId** | `string` | optional | Filter by owning package | | **search** | `string` | optional | Full-text search query | @@ -230,6 +230,7 @@ const result = MetadataBulkResultSchema.parse(data); * `hook` * `seed` * `mapping` +* `picklist` * `view` * `page` * `dashboard` @@ -262,7 +263,7 @@ const result = MetadataBulkResultSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| … +12 more>` | ✅ | Metadata type identifier | +| **type** | `Enum<'object' \| 'field' \| 'hook' \| 'seed' \| 'mapping' \| 'picklist' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| … +13 more>` | ✅ | Metadata type identifier | | **label** | `string` | ✅ | Display label for the metadata type | | **description** | `string` | optional | Description of the metadata type | | **filePatterns** | `string[]` | ✅ | Glob patterns to discover files of this type | @@ -282,6 +283,7 @@ const result = MetadataBulkResultSchema.parse(data); * `hook` * `seed` * `mapping` +* `picklist` * `view` * `page` * `dashboard` diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index e72f029b5a4..a7b7e3a90b1 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -76,6 +76,7 @@ Add a new field to an existing object | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect | +| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). The server resolves the reference: the field clients read carries the resolved `options`. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | @@ -496,6 +497,7 @@ Add a new field to an existing object | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect | +| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). The server resolves the reference: the field clients read carries the resolved `options`. | | **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 708a0fa54ec..8d47dea85b1 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -154,7 +154,7 @@ Translation data for a single field Map of locale codes to platform translation data -**Type:** `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>` +**Type:** `Record; picklists?: Record; apps?: Record; messages?: Record; … }>` --- @@ -168,6 +168,7 @@ Platform translation data — the per-app groups plus the platform-only `setting | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objects** | `Record; … }>` | optional | Object translations keyed by object name | +| **picklists** | `Record }>` | optional | Picklist translations keyed by picklist name | | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | | **globalActions** | `Record` | optional | Global action translations keyed by action name | @@ -195,6 +196,13 @@ Translation data for a single object | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | | **_validations** | `Record` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) | +### Nested Shape: `PlatformTranslationData.picklists[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated picklist label | +| **options** | `Record` | ✅ | Option value to translated label map — inherited by every field that references the picklist | + ### Nested Shape: `PlatformTranslationData.apps[string]` | Property | Type | Required | Description | @@ -282,7 +290,7 @@ Translation data for a single object Map of locale codes to per-app translation data -**Type:** `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>` +**Type:** `Record; picklists?: Record; apps?: Record; messages?: Record; … }>` --- @@ -358,6 +366,7 @@ Per-app translation data for objects, apps, and UI messages | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objects** | `Record; … }>` | optional | Object translations keyed by object name | +| **picklists** | `Record }>` | optional | Picklist translations keyed by picklist name | | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | | **globalActions** | `Record` | optional | Global action translations keyed by action name | @@ -384,6 +393,13 @@ Translation data for a single object | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | | **_validations** | `Record` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) | +### Nested Shape: `TranslationData.picklists[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated picklist label | +| **options** | `Record` | ✅ | Option value to translated label map — inherited by every field that references the picklist | + ### Nested Shape: `TranslationData.apps[string]` | Property | Type | Required | Description | @@ -498,6 +514,7 @@ One locale of translations — the `translation` metadata type | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objects** | `Record; … }>` | optional | Object translations keyed by object name | +| **picklists** | `Record }>` | optional | Picklist translations keyed by picklist name | | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | | **globalActions** | `Record` | optional | Global action translations keyed by action name | @@ -534,6 +551,13 @@ Translation data for a single object | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | | **_validations** | `Record` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) | +### Nested Shape: `TranslationItem.picklists[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated picklist label | +| **options** | `Record` | ✅ | Option value to translated label map — inherited by every field that references the picklist | + ### Nested Shape: `TranslationItem.apps[string]` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 839ec012138..82ba3410280 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 158 | 75 | 1 | 0 | 82 | +| `data/` | 161 | 77 | 2 | 0 | 82 | ## `data/` — sites @@ -52,11 +52,12 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `hook.zod.ts` | 7 | | `mapping.zod.ts` | 3 | | `object.zod.ts` | 21 | +| `picklist.zod.ts` | 3 | | `query.zod.ts` | 5 | | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 1 | | `validation.zod.ts` | 6 | -| **total** | **158** | +| **total** | **161** | ## `data/` — open @@ -64,7 +65,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**82 strip of 158**, in 11 file(s). +**82 strip of 161**, in 11 file(s). | File | Strip | Sites | |---|---|---| @@ -79,7 +80,7 @@ over it is here. | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **82** | **158** | +| **total** | **82** | **161** | | Bucket | Sites | |---|---| diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index f8d523a513b..84cee18fd58 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 352 | +| `system/` | 353 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index c3bde543ce9..11a2ef47fef 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -729,6 +729,7 @@ column does not move and the `strip` column falls by the count of what left. | `document.zod.ts` | wire (p) | | | `hook.zod.ts` / `hook-body.zod.ts` | mixed | **strict as of #4001 data step** for the AUTHORING shapes: `HookSchema` (+ `retryPolicy`) and both body branches (`ExpressionBodySchema` / `ScriptBodySchema`). `HookContextSchema` and its `session` / `provenance` / `user` blocks are the RUNTIME shape the engine hands a handler — they stay tolerant, and must: strictness there would make an engine-internal enrichment (as `provenance` was in #3712) a breaking change for anyone parsing a context they were given. The file's old blanket `authorable (p)` was too wide — verification split it | | `mapping.zod.ts` | authorable (p) | | +| `picklist.zod.ts` | mixed | `PicklistSchema` and `PicklistExtensionSchema` are authoring shapes, strict from birth (`strictObject`). `PicklistServedFieldSchema` is a SERVED shape — the slice a resolved picklist reference adds to a served field — and is a `looseObject` by design: every other key of that field is `FieldSchema`'s | | `external-catalog.zod.ts` | wire (p) | | | `validation.zod.ts` | authorable | **strict as of #4001 batch 3b** — a `z.lazy()` discriminated union, so the one-call conversion does not apply: each of the six variants builds its own `strictObject` from a shared `BASE_VALIDATION_SHAPE`. Closing the base alone would have rejected correctly but suggested from the SHARED keys only, so a typo of a variant's own key (`transtions` → `transitions`) would get no rename. Site count 1 → 6 because the six variants are now object sites in their own right. The ADR-0010 envelope lives in the shared shape, so all six inherit it | | `field-value.zod.ts` / `seed.zod.ts` | authorable | `seed` is strict (registered-types batch). **`field-value` strict as of #13802 (2026-09-02) — re-verdicted `open` → `authorable` by maintainer ruling 2026-09-01 (option A, director batch #26, verbatim 「同意」), overruling this row's #4001 batch D reading.** Both value contracts (`LocationValueSchema`, `AddressSchema` = `AddressValueSchema`) are `strictObject` now; `FileValueSchema` stays `z.looseObject` on purpose — the one deliberate loose site, ⛔ untouched. What batch D got wrong was not the door census but the cost model: every member of both shapes is OPTIONAL, so under `.strip` a value with a completely wrong key set still parsed green and the wrong keys vanished — the showcase seed wrote `postal_code`, the platform accepted it, dropped it, and rendered an empty ZIP box (#13388; found by objectui#6812's survey; #5143 had named the same stripping on the widget round-trip) — while any stored-value scan over the class could only ever report zero (#13802's finding: an instrument reporting a number it has no way to earn). The "legitimate extras" (`heading`/`speed`, `district`) were a hypothesis with no in-repo producer. **Corpus census at `a39b02a6`, ordered first by the ruling**: every in-repo corpus that writes address/location VALUES — `examples/**`, `packages/apps/**`, `packages/qa/**`, `packages/**/src` fixtures and tests, `content/docs/**`, `skills/**`, `.changeset/**`, `docs/**` (`.ts/.tsx/.js/.mjs/.cjs/.json/.yaml/.yml/.md/.mdx`; generated `references/`, `releases/`, CHANGELOGs excluded) — a brace-matched scan of 8459 files / 196,098 leaf object literals found 57 address- or location-shaped literals and **0 carrying a key outside the declared sets** other than batch D's own tolerance pin in `analytics-strictness-batchd.test.ts` (repinned to the closure in the same stroke; the SCIM hits are `SCIMAddressSchema`, a different contract). The spelling grep `git grep -n -E "postal_code|zip_code|zipCode|postcode|latitude|longitude|heading *:|speed *:|district *:" -- 'examples/**' 'packages/apps/**' 'packages/qa/**' 'packages/spec/**' 'content/docs/**' 'skills/**'` hits only the retired-form docs, `ListMapConfig`'s field-name keys and pins. So the repair commit the ruling ordered first was EMPTY by measurement — #13388's seed fix had already landed at #14090. **Migration note — where the refusal bites, by call site** (`git grep -n "valueSchemaFor(" -- packages`, non-test): ① **authoring, hard reject, unconditional** — a `location`/`address` field's literal `defaultValue` (`default-value-shape.ts` `checkLiteralDefaultValue` → `FieldSchema`, #7127) and an action-param value of those types (`ui/action-params.zod.ts` `validateActionParams`, D2 strict by default since 17.0); ② **record writes, per deployment** — objectql `record-validator` `validateOne` (`shapeSchemaFor(def).safeParse(value)`, insert and update) rejects with `invalid_type` / `invalid_value_shape` ONLY when `valueShapeStrictEffective` holds (the deployment attested `adr-0104-value-shapes`, or `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`; `OS_ALLOW_LAX_VALUE_SHAPES=1` re-opens), otherwise **warn-first** — logged once per field and reported to the admitted-violation sink (#4769), unchanged; ③ **the scan** — `os migrate value-shapes` (`valueShapeViolation`, the same predicate) now COUNTS an undeclared key, so a deployment holding such values cannot attest until they are cleaned at the producer — the mechanism that keeps ② from stranding stored data; ④ **read paths: none** — no consumer calls `valueSchemaFor(def, 'expanded')` for these types outside `packages/spec` (`git grep "'expanded')" -- 'packages/**/src/**' ':!packages/spec/**'` → 0 hits), drivers return the stored JSON verbatim and renderers read it, so a stored `{ …, postal_code }` still reads back as written. ⛔ Per the ruling no read path was narrowed; the customer-database inventory is the one thing this repo cannot see — a confidence gap recorded here, not glossed: the scan is the instrument that closes it per deployment. ⛔ No consumer-side alias (AGENTS.md #0.1): the `aliases` on both shapes are did-you-mean RENAMES in the refusal message (`zipCode`/`zip`/`postcode` → `postalCode`, `latitude`/`longitude` → `lat`/`lng`), not tolerance — `postal_code` is refused, never read. objectui at the pin (`LocationField.tsx`) `safeParse`s only a widget-built `{ lat, lng, altitude?, accuracy? }` candidate, so its verdicts do not move; its `LocationField.optionalKeys.test.tsx` pins the OLD strip behaviour and flips the day objectui takes a spec carrying this — filed there, not here. D3 semantic entry `address-location-value-unknown-keys-refused` (protocol 18). Re-check: `git grep -n "strictObject(\|looseObject(\|z.object(" -- packages/spec/src/data/field-value.zod.ts` → two `strictObject(`, one `z.looseObject(`, zero `z.object(` | diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 57fa6379847..cd4a15d15c0 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -75,6 +75,12 @@ export const KIND_COVERAGE: Record = { notes: 'Named import mapping resolved via mappingName at POST /data/:object/import (#2611); promoted to a registry kind per the ADR-0088 admission test.', }, + picklist: { + status: 'waived', + reason: + 'The kind is declared ahead of its runtime reader: until the server resolves a picklist reference, a picklist-bound field is served with no options, so a demo would draw a select with nothing to choose. Demonstrate it (one list on two objects, an extension, a translated option) with the runtime layer.', + issue: 'https://github.com/objectstack-ai/objectstack/issues/19519', + }, // ── ui ── view: { status: 'demonstrated', files: ['src/ui/views/task.view.ts', 'src/ui/views/project.view.ts'] }, @@ -200,6 +206,12 @@ export const STACK_COLLECTION_COVERAGE: Record = { files: ['src/data/extensions/account.extension.ts'], notes: 'Merged into showcase_account by the ObjectQL engine at registerApp (priority overlay).', }, + picklistExtensions: { + status: 'waived', + reason: + 'Declared with the picklist kind, ahead of the runtime merge that adds an extension\'s options to the list it extends; demonstrated alongside `KIND_COVERAGE.picklist` when that layer lands.', + issue: 'https://github.com/objectstack-ai/objectstack/issues/19519', + }, // `apis` is NOT listed here any more: as of #5271 it is a registry kind, so // its coverage lives in `KIND_COVERAGE.api` above. Leaving a duplicate row in // this manifest — whose contract is "stack collections that are NOT registry diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index c25440746ed..8eafdc248a3 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -67,6 +67,7 @@ export interface CoverageIssue { | 'dashboard' | 'widget' | 'dataset' + | 'picklist' | 'page' | 'flow' | 'metadataForm'; @@ -349,6 +350,10 @@ const COVERAGE_SOURCE: Record // the `dashboard` bucket: the string is defined once, not once per // presentation. dataset: 'dataset', + // Shared option-list copy (`picklists.

.label`, `.options.`) — the + // author's own vocabulary, translated once for every field that references + // it, so it keeps its own bucket and reports as `i18n/missing-picklist`. + picklist: 'picklist', page: 'page', // Screen-flow copy (`flows..label`, `flows..screens..title`, and // the per-field `label` / `placeholder`) — the author's own wizard text, so @@ -376,6 +381,7 @@ const SOURCE_NOUN: Record = { dashboard: 'Dashboard', widget: 'Widget', dataset: 'Dataset', + picklist: 'Picklist', page: 'Page', flow: 'Flow', metadataForm: 'Metadata form', @@ -417,6 +423,7 @@ const SOURCE_SURFACE: Record = { dashboard: 'dashboards', widget: 'widgets', dataset: 'datasets', + picklist: 'picklists', page: 'pages', flow: 'flow screens', metadataForm: 'metadata forms', diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index a37f3fbf83b..37843a0e963 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -96,6 +96,8 @@ * datasets..label / .description * datasets..dimensions..label * datasets..measures..label + * picklists..label + * picklists..options. (a `picklistExtensions` entry's options too) * pages..label / .description * pages..title / .subtitle (from the page's `page:header` component) * pages..components.. (per-component copy, #6080) @@ -202,6 +204,7 @@ export interface ExpectedEntry { | 'dashboard' | 'widget' | 'dataset' + | 'picklist' | 'page' | 'flow' | 'metadataType' @@ -1392,6 +1395,9 @@ export function collectExpectedEntries( // ── Analytics datasets (`datasets..…`) ───────────────────── walkDatasets(config, out); + // ── Shared option lists (`picklists..…`) ──────────────────── + walkPicklists(config, out); + // ── Pages + their `page:header` copy ────────────────────────────── const pages: any[] = Array.isArray(config?.pages) ? config.pages : []; for (const page of pages) { @@ -1577,6 +1583,58 @@ function walkDatasets(config: any, out: ExpectedEntry[]): void { } } +// ─── Shared option lists (`picklists..…`) ──────────────────────── + +/** + * Emit the picklist copy surface: + * + * picklists..label + * picklists..options. + * + * A picklist is translated ONCE: every field that references it inherits the + * option labels (`translateObject`), so the keys live under the list and not + * under each field. A picklist-bound field declares no `options` of its own, + * so the field walk above emits nothing for it — this walk is the only + * producer of those keys. A `picklistExtensions` entry adds options to a list + * another package owns, and they are served as that list's options, so their + * labels are keyed under the extended list's name. + * + * Both labels are plain strings at the authoring site, so `pushEntry` — with + * the #8543 derived rule a field option follows: an option whose label is + * absent or equals its own machine value is seeded from the value and never + * demanded as a translation. + */ +function walkPicklists(config: any, out: ExpectedEntry[]): void { + const walkOptions = (name: string, options: unknown): void => { + if (!Array.isArray(options)) return; + for (const option of options) { + if (!option || typeof option !== 'object' || typeof option.value !== 'string') continue; + const path = ['picklists', name, 'options', option.value]; + const authored = inlineText(option.label); + if (authored !== undefined && authored !== option.value) { + pushEntry(out, path, authored, 'picklist'); + } else { + pushDerived(out, path, option.value, inlineLocaleMap(option.label) ? option.label : undefined, 'picklist'); + } + } + }; + const picklists: any[] = Array.isArray(config?.picklists) ? config.picklists : []; + for (const picklist of picklists) { + if (!picklist || typeof picklist !== 'object') continue; + const name = picklist.name; + if (typeof name !== 'string' || name.length === 0) continue; + pushEntry(out, ['picklists', name, 'label'], picklist.label, 'picklist'); + walkOptions(name, picklist.options); + } + const extensions: any[] = Array.isArray(config?.picklistExtensions) ? config.picklistExtensions : []; + for (const extension of extensions) { + if (!extension || typeof extension !== 'object') continue; + const name = extension.extend; + if (typeof name !== 'string' || name.length === 0) continue; + walkOptions(name, extension.options); + } +} + // ─── Screen flows (`flows..screens..…`) ───────────────── /** diff --git a/packages/cli/test/i18n-picklist-walk.test.ts b/packages/cli/test/i18n-picklist-walk.test.ts new file mode 100644 index 00000000000..eb249cd3499 --- /dev/null +++ b/packages/cli/test/i18n-picklist-walk.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// `picklists..…` — the extractor walks the shared option-list group. +// +// A picklist is translated once, under the list: every field that references +// it inherits the option labels (`translateObject`), and a picklist-bound field +// declares no `options` of its own, so the field walk emits nothing for it. +// Without this walk `os i18n extract` scaffolded no key for the group and +// `check:i18n-coverage` measured a debt of zero while the labels shipped in +// English (`check:i18n-walk-parity`). + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { TranslationDataSchema } from '@objectstack/spec/system'; + +const picklistEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.path[0] === 'picklists'); + +const picklistKeys = (config: any): string[] => picklistEntries(config).map((e) => e.path.join('.')); + +const config = () => ({ + picklists: [ + { + name: 'industry', + label: 'Industry', + options: [ + { label: 'Technology', value: 'technology' }, + { label: 'Finance', value: 'finance' }, + ], + }, + ], + picklistExtensions: [ + { extend: 'industry', options: [{ label: 'Healthcare', value: 'healthcare' }] }, + ], + objects: [ + { + name: 'account', + label: 'Account', + fields: { industry: { type: 'select', label: 'Industry', picklist: 'industry' } }, + }, + ], +}); + +describe('i18n extract — the `picklists` group', () => { + it('walks the list label and every option, an extension\'s options under the extended list', () => { + expect(picklistKeys(config()).sort()).toEqual([ + 'picklists.industry.label', + 'picklists.industry.options.finance', + 'picklists.industry.options.healthcare', + 'picklists.industry.options.technology', + ]); + }); + + it('seeds each key with the authored literal', () => { + const byKey = new Map(picklistEntries(config()).map((e) => [e.path.join('.'), e])); + expect(byKey.get('picklists.industry.label')).toMatchObject({ sourceValue: 'Industry', inline: 'Industry', source: 'picklist' }); + expect(byKey.get('picklists.industry.options.healthcare')).toMatchObject({ sourceValue: 'Healthcare', inline: 'Healthcare' }); + }); + + it('seeds an option whose label is its own value, but demands no translation of it', () => { + const entry = picklistEntries({ + picklists: [{ name: 'tier', label: 'Tier', options: [{ label: 'gold', value: 'gold' }] }], + }).find((e) => e.path.join('.') === 'picklists.tier.options.gold'); + expect(entry?.sourceValue).toBe('gold'); + expect(entry?.inline).toBeUndefined(); + }); + + it('emits no field-level option key for a picklist-bound field', () => { + const fieldOptionKeys = collectExpectedEntries(config()) + .map((e) => e.path.join('.')) + .filter((k) => k.startsWith('objects.account.fields.industry.options.')); + expect(fieldOptionKeys).toEqual([]); + }); + + it('`TranslationDataSchema` accepts a bundle written at the extracted paths', () => { + const out = extractTranslations(config(), { locales: ['zh-CN'] }); + const zh = (out.bundles['zh-CN'] as any)?.picklists; + expect(zh?.industry?.label).toBeDefined(); + expect(Object.keys(zh?.industry?.options ?? {}).sort()).toEqual(['finance', 'healthcare', 'technology']); + expect(TranslationDataSchema.safeParse({ picklists: zh }).success).toBe(true); + }); + + it('reports an untranslated option under its own bucket, `i18n/missing-picklist`', () => { + const report = computeI18nCoverage({ + ...config(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [ + { 'zh-CN': { picklists: { industry: { label: '行业', options: { technology: '科技' } } } } }, + ], + }); + const issues = report.issues.filter((i) => i.key.startsWith('picklists.')); + const keys = issues.map((i) => i.key); + expect(keys).toContain('picklists.industry.options.finance'); + expect(keys).not.toContain('picklists.industry.options.technology'); + expect(keys).not.toContain('picklists.industry.label'); + expect(issues.every((i) => i.source === 'picklist')).toBe(true); + }); +}); diff --git a/packages/cli/test/metadata-type-schema-gate.test.ts b/packages/cli/test/metadata-type-schema-gate.test.ts index 45c4fbc80a4..0ee7d48349c 100644 --- a/packages/cli/test/metadata-type-schema-gate.test.ts +++ b/packages/cli/test/metadata-type-schema-gate.test.ts @@ -77,6 +77,10 @@ const GATED_AT: Readonly> = { action: 'actions', report: 'reports', dataset: 'datasets', + // [#19518] `picklist` registered as a closed shape (`PicklistSchema` is + // `strictObject`), and the stack authors it at `picklists:` as a flat array + // of that same shape, so it is gated from the start. + picklist: 'picklists', flow: 'flows', job: 'jobs', datasource: 'datasources', diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts index 83822b1f168..288f3157502 100644 --- a/packages/drivers/driver-sql/src/builtin-column-collision.ts +++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts @@ -104,6 +104,7 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> = rows: 'presentation', // inline multiline-editor height (objectui#6140) — read by objectui's TextAreaField/RichTextField, never by the DDL (a rows-sized editor surface, not a column shape) useGrouping: 'presentation', options: 'presentation', // select values: validation + UI, no DDL + picklist: 'presentation', // names the shared option list the server resolves into `options` — validation + UI, no DDL accept: 'presentation', // upload validation maxSize: 'presentation', // upload validation language: 'presentation', // editor language hint diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index a73816c6948..5cbbb941ad7 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -1432,8 +1432,10 @@ describe('the object/field walk, against a synthetic ledger directory (#19268)', // If `field.json` ever warns again these two flip, and the block above // ("field walk: a malformed `fields` array …") can take its real subject back // — but this block keeps working either way, which is the point. - it('the SHIPPED field ledger warns on nothing today — which is why the walk needs a subject of its own', () => { - expect([...authorWarnedProperties('field')]).toEqual([]); + it('the SHIPPED field ledger warns on `picklist` alone — which is why the walk needs a subject of its own', () => { + // `picklist` is `planned` + `authorWarn` until the server resolves picklist + // references; the synthetic slot below is still warned by nothing shipped. + expect([...authorWarnedProperties('field')]).toEqual(['picklist']); expect( lintLivenessProperties({ objects: [{ name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }] }], diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index 34b6816293f..94d72803c8f 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -153,6 +153,17 @@ const PROBES: Record }> = type: 'text', }, }, + // `picklist` is package-owned (`allowRuntimeCreate: false`): a shared option + // list ships in a package (`*.picklist.ts`), and a per-org overlay is a later + // phase. Schema-valid for the same reason as the rest. + picklist: { + name: 'rc3_picklist_probe', + item: { + name: 'rc3_picklist_probe', + label: 'Probe', + options: [{ label: 'One', value: 'one' }], + }, + }, }; function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { @@ -269,7 +280,7 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { // auto-enrolment is the point of deriving the set instead of listing it // (Prime Directive #8). expect(CODE_ONLY_TYPES.length).toBeGreaterThan(0); - expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'api', 'capability', 'field', 'job']); + expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'api', 'capability', 'field', 'job', 'picklist']); for (const type of CODE_ONLY_TYPES) { expect(PROBES[type], `no probe payload for code-only type '${type}'`).toBeDefined(); } diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 43a4c61af3a..77712d05733 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -297,10 +297,11 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * Positive control. These are the card's own thirteen counts, reproduced * against the SERVED document. They prove two things at once: this suite * measures the same thing the card measured, and none of the thirteen was - * disturbed by the fix. + * disturbed by the fix. `field` moved 74 → 75 when it gained `picklist` + * (the shared-option-list reference), a declared key, not a derivation change. */ const CARD_PROPERTY_COUNTS: Record = { - agent: 26, app: 30, dashboard: 21, dataset: 16, field: 74, flow: 23, + agent: 26, app: 30, dashboard: 21, dataset: 16, field: 75, flow: 23, hook: 22, object: 43, page: 24, position: 12, report: 21, skill: 17, tool: 14, }; diff --git a/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts b/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts index 1468f58bbab..61bfccaafec 100644 --- a/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts +++ b/packages/metadata-protocol/src/protocol.recovery-doors-emit-mutation.test.ts @@ -561,7 +561,7 @@ describe('[#14179] reachability: the legacy exit is confined to bootstrap mode', // Non-empty: the branch is not dead code. expect(codeOnly.length).toBeGreaterThan(0); expect(codeOnly.map((e) => e.type).sort()) - .toEqual(['agent', 'api', 'capability', 'field', 'job']); + .toEqual(['agent', 'api', 'capability', 'field', 'job', 'picklist']); // …and the #6960 carve-out reaches none of them, which is what makes // the 403 below total rather than incidental. expect(codeOnly.filter((e) => e.supportsOverlay)).toEqual([]); diff --git a/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts index 19a8c76afb7..157c0e46a85 100644 --- a/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/bare-type-display-echo-decisions.test.ts @@ -766,28 +766,29 @@ describe('#19403 round 7 — the provenance table agrees these leaves are now au }); describe('#19403 round 7 — the population, DERIVED from the registry and a shape', () => { - it('⭐ the registry and the catalog describe the same 27 metadata types', () => { + it('⭐ the registry and the catalog describe the same 28 metadata types', () => { // The ratchet's anchor. The catalog is GENERATED from this registry, so a // new metadata type arrives in both at once — which is what makes the bare // class below pick up a future unauthored display pair without anybody // adding it to a list. - expect(REGISTRY_TYPES.length).toBe(27); + // 27 → 28: `picklist` joined the registry, a bare type (no form) with a description. + expect(REGISTRY_TYPES.length).toBe(28); expect([...REGISTRY_TYPES].sort()).toEqual(Object.keys(enMetadataForms as Record).sort()); }); it('the BARE predicate splits that population, and is read off the SHAPE not a name list', () => { // Lit. - expect(BARE_TYPES.length).toBe(10); + expect(BARE_TYPES.length).toBe(11); expect(PANEL_TYPES.length).toBe(17); expect(BARE_TYPES.length + PANEL_TYPES.length).toBe(REGISTRY_TYPES.length); - for (const type of ['seed', 'mapping', 'api', 'doc', 'book', 'capability']) { + for (const type of ['seed', 'mapping', 'api', 'doc', 'book', 'capability', 'picklist']) { expect(BARE_TYPES, `${type} is bare`).toContain(type); } for (const type of AUTHORED_BARE_TYPES) expect(BARE_TYPES, `${type} is bare`).toContain(type); - // 16 leaves: ten labels, and a description on the six that carry one. - expect(BARE_LEAVES.length).toBe(16); - expect(BARE_LEAVES.filter((l) => l.prop === 'label').length).toBe(10); - expect(BARE_LEAVES.filter((l) => l.prop === 'description').length).toBe(6); + // 18 leaves: eleven labels, and a description on the seven that carry one. + expect(BARE_LEAVES.length).toBe(18); + expect(BARE_LEAVES.filter((l) => l.prop === 'label').length).toBe(11); + expect(BARE_LEAVES.filter((l) => l.prop === 'description').length).toBe(7); expect(BARE_LEAVES.every((l) => ['label', 'description'].includes(l.prop))).toBe(true); }); @@ -806,8 +807,8 @@ describe('#19403 round 7 — the population, DERIVED from the registry and a sha expect(registryEntry('dataset')?.description, 'dataset carries a registry description like the six').toBeTruthy(); expect(isBare('dataset')).toBe(false); const described = REGISTRY_TYPES.filter((t) => typeof registryEntry(t)?.description === 'string'); - expect([...described].sort()).toEqual(['api', 'book', 'capability', 'dataset', 'doc', 'mapping', 'seed']); - expect(described.filter(isBare).sort()).toEqual(['api', 'book', 'capability', 'doc', 'mapping', 'seed']); + expect([...described].sort()).toEqual(['api', 'book', 'capability', 'dataset', 'doc', 'mapping', 'picklist', 'seed']); + expect(described.filter(isBare).sort()).toEqual(['api', 'book', 'capability', 'doc', 'mapping', 'picklist', 'seed']); // And no panel type's own display pair echoes — the exclusion removes only // leaves that are already decided elsewhere or already authored. const panelEchoes: string[] = []; @@ -868,13 +869,13 @@ describe('#19403 round 7 — the population, DERIVED from the registry and a sha expect(flagged.length).toBe(BARE_LEAVES.length); }); - it('⭐ this class is now DONE — zero of its 16 leaves echoes in all three locales', () => { + it('⭐ this class is now DONE — zero of its 18 leaves echoes in all three locales', () => { const echoing = BARE_LEAVES.filter((l) => TRANSLATED_LOCALES.every(([, forms]) => forms[l.type]?.[l.prop] === l.en), ); expect(echoing.map((l) => `${l.type}.${l.prop}`)).toEqual([]); // Lit — and it really walked the class, which a zero alone would not show. - expect(BARE_LEAVES.length).toBe(16); + expect(BARE_LEAVES.length).toBe(18); }); }); 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 185bd19aaac..ca926307c2d 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 @@ -923,6 +923,10 @@ export const enMetadataForms: NonNullable = { label: "Seed Data", description: "Fixture / initialization data applied on publish" }, + picklist: { + label: "Picklist", + description: "Shared option list that select fields reference by name" + }, mapping: { label: "Import Mapping", description: "Reusable import/export field mapping (rename + transforms), referenced by name at import" 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 4faaa448e5a..4e3a876e678 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 @@ -923,6 +923,10 @@ export const esESMetadataForms: NonNullable = label: "Datos semilla", description: "Datos predefinidos / de inicialización aplicados al publicar" }, + picklist: { + label: "Lista de selección", + description: "Lista de opciones compartida a la que los campos de selección hacen referencia por nombre" + }, mapping: { label: "Mapeo de importación", description: "Mapeo de campos de importación/exportación reutilizable (renombrado + transformaciones), referenciado por nombre al importar" 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 1b912e07c27..13fea54f717 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 @@ -923,6 +923,10 @@ export const jaJPMetadataForms: NonNullable = label: "シードデータ", description: "公開時に適用されるフィクスチャ/初期化データ" }, + picklist: { + label: "選択リスト", + description: "選択フィールドが名前で参照する共有の選択肢リスト" + }, mapping: { label: "インポートマッピング", description: "再利用可能なインポート/エクスポートのフィールドマッピング(リネーム + 変換)。インポート時に名前で参照します" 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 6525a44bd03..9797c50c868 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 @@ -1146,7 +1146,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // sub-rows), and the field form's `inlineColumns` repeater (and its // `name`, `label`, `width` and `defaultHidden` sub-rows) — authored in all // three locales. - expect(translated.length, `${locale} positive control`).toBe(657); + // 658 with the `picklist` metadata type's display pair, authored in all + // three locales. + expect(translated.length, `${locale} positive control`).toBe(658); } // ⭐ 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 3a732469987..07e39327f02 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 @@ -923,6 +923,10 @@ export const zhCNMetadataForms: NonNullable = label: "种子数据", description: "发布时应用的预置/初始化数据" }, + picklist: { + label: "共享选项集", + description: "选择字段按名称引用的共享选项列表" + }, mapping: { label: "导入映射", description: "可复用的导入/导出字段映射(重命名 + 转换),在导入时按名称引用" diff --git a/packages/runtime/src/domains/meta-list-projection-parity.test.ts b/packages/runtime/src/domains/meta-list-projection-parity.test.ts index 1587a07016a..40e9131c724 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -165,6 +165,7 @@ const STORE: Record = { page: [{ name: 'home', label: 'Home', type: 'app' }], action: [{ name: 'close_case', label: 'Close case', objectName: 'case', type: 'script' }], dataset: [{ name: 'pipeline', label: 'Pipeline', object: 'opportunity' }], + picklist: [{ name: 'industry', label: 'Industry', options: [{ label: 'Technology', value: 'technology' }] }], flow: [{ name: 'on_lead', label: 'On lead', type: 'autolaunched' }], }; @@ -484,7 +485,7 @@ const PARAM_AXIS = new Set(PARAM_PROBES.map((p) => p.param).filter((p): p is str /** Every type a projection or a gate keys on, both spellings, plus every translatable type and an untouched control (`flow`). */ const TYPE_CELLS: readonly string[] = [ 'app', 'apps', 'view', 'views', 'doc', 'docs', 'book', 'books', 'api', 'apis', - 'object', 'objects', 'dashboard', 'dashboards', 'page', 'action', 'dataset', 'flow', + 'object', 'objects', 'dashboard', 'dashboards', 'page', 'action', 'dataset', 'picklist', 'flow', // [#20408] A segment that names no metadata type: `RestServer` refuses it // (`refuseUnknownMetaListType`) rather than listing an empty collection. 'totally_invented_type', @@ -790,7 +791,7 @@ const ITEM_CELLS: readonly string[] = [ '/meta/book/admin_guide', '/meta/books/help_center', '/meta/book/public_guide', '/meta/object/invoice', '/meta/objects/invoice', '/meta/dashboard/ops', '/meta/dashboards/ops', - '/meta/page/home', '/meta/action/close_case', '/meta/dataset/pipeline', '/meta/flow/on_lead', '/meta/api/crm_served', + '/meta/page/home', '/meta/action/close_case', '/meta/dataset/pipeline', '/meta/picklist/industry', '/meta/flow/on_lead', '/meta/api/crm_served', ]; const ITEM_TYPE_AXIS = new Set(ITEM_CELLS.map((c) => singular(c.split('/')[2]))); const ITEM_CALLERS = ['holder', 'non-holder', 'builder', 'author'] as const satisfies readonly CallerName[]; diff --git a/packages/spec/api-surface-signatures.json b/packages/spec/api-surface-signatures.json index b2099d11828..1d8462c6d1e 100644 --- a/packages/spec/api-surface-signatures.json +++ b/packages/spec/api-surface-signatures.json @@ -16,6 +16,7 @@ "defineObjectExtension": "sha256:3b4099069f01adfb", "definePage": "sha256:770a5cdb49ceba18", "definePermissionSet": "sha256:78d05bdfde46dc0d", + "definePicklist": "sha256:1f1e9009f8234797", "definePosition": "sha256:e11854797497e5e9", "defineReport": "sha256:044acff031d89910", "defineSharingRule": "sha256:239e6649b55712a4", diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 4900a7de75f..3c53fe5b1b3 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -546,6 +546,15 @@ "PersistenceAdapterSchema (const)", "PersistenceType (type)", "PersistenceTypeSchema (const)", + "Picklist (type)", + "PicklistExtension (type)", + "PicklistExtensionParsed (type)", + "PicklistExtensionSchema (const)", + "PicklistParsed (type)", + "PicklistSchema (const)", + "PicklistServedField (type)", + "PicklistServedFieldParsed (type)", + "PicklistServedFieldSchema (const)", "PoolConfig (type)", "PoolConfigParsed (type)", "PoolConfigSchema (const)", @@ -788,6 +797,7 @@ "defineHook (function)", "defineMapping (function)", "defineObjectExtension (function)", + "definePicklist (function)", "defineSeed (function)", "deriveFieldGroupLayout (function)", "deriveRecordFlowSurface (function)", diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 13cff2c5d59..552a545a403 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -164,6 +164,7 @@ "defineObjectExtension (function)", "definePage (function)", "definePermissionSet (function)", + "definePicklist (function)", "definePosition (function)", "defineReport (function)", "defineSharingRule (function)", diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 50472f743ce..fa002c24036 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -497,6 +497,7 @@ "PageComponentLike (interface)", "PageLike (interface)", "PageRegionLike (interface)", + "PicklistLike (interface)", "Plan (type)", "PlanParsed (type)", "PlanSchema (const)", @@ -850,6 +851,7 @@ "translateMetadataDocument (function)", "translateObject (function)", "translatePage (function)", + "translatePicklist (function)", "translateView (function)", "validationMessageTranslationKey (function)", "walkAddressedPageComponents (function)" diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 1b83553c9fc..c83f29738c8 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -388,6 +388,7 @@ "data/Field:multiple", "data/Field:name", "data/Field:options", + "data/Field:picklist", "data/Field:placeholder", "data/Field:precision", "data/Field:readonly", @@ -753,6 +754,21 @@ "data/PerOperationRequiredPermissions:delete", "data/PerOperationRequiredPermissions:read", "data/PerOperationRequiredPermissions:update", + "data/Picklist:_lock", + "data/Picklist:_lockDocsUrl", + "data/Picklist:_lockReason", + "data/Picklist:_lockSource", + "data/Picklist:_packageId", + "data/Picklist:_packageVersion", + "data/Picklist:_provenance", + "data/Picklist:description", + "data/Picklist:label", + "data/Picklist:name", + "data/Picklist:options", + "data/PicklistExtension:extend", + "data/PicklistExtension:options", + "data/PicklistServedField:options", + "data/PicklistServedField:picklist", "data/PoolConfig:connectionTimeoutMillis", "data/PoolConfig:idleTimeoutMillis", "data/PoolConfig:max", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index f45d62f762d..fda3690a727 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -888,6 +888,7 @@ "system/PlatformTranslationData:metadataForms", "system/PlatformTranslationData:objects", "system/PlatformTranslationData:pages", + "system/PlatformTranslationData:picklists", "system/PlatformTranslationData:settings", "system/PlatformTranslationData:settingsCommon", "system/PresignedUrlConfig:contentType", @@ -1287,6 +1288,7 @@ "system/TranslationData:metadataForms", "system/TranslationData:objects", "system/TranslationData:pages", + "system/TranslationData:picklists", "system/TranslationData:settingsCommon", "system/TranslationDiffItem:aiConfidence", "system/TranslationDiffItem:aiSuggested", @@ -1314,6 +1316,7 @@ "system/TranslationItem:name", "system/TranslationItem:objects", "system/TranslationItem:pages", + "system/TranslationItem:picklists", "system/TranslationItem:settingsCommon", "system/VectorClock:clock", "system/WorkerStats:active", diff --git a/packages/spec/declaration-map/data.json b/packages/spec/declaration-map/data.json index e93c8746bd0..987884d9b19 100644 --- a/packages/spec/declaration-map/data.json +++ b/packages/spec/declaration-map/data.json @@ -245,6 +245,12 @@ "PerOperationRequiredPermissionsSchema": "data/PerOperationRequiredPermissions", "PersistenceType": "data/PersistenceType", "PersistenceTypeSchema": "data/PersistenceType", + "Picklist": "data/Picklist", + "PicklistExtension": "data/PicklistExtension", + "PicklistExtensionSchema": "data/PicklistExtension", + "PicklistSchema": "data/Picklist", + "PicklistServedField": "data/PicklistServedField", + "PicklistServedFieldSchema": "data/PicklistServedField", "PoolConfig": "data/PoolConfig", "PoolConfigSchema": "data/PoolConfig", "PostgresConfig": "data/PostgresConfig", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index bcafcdced6d..129de63c943 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -535,6 +535,15 @@ "PersistenceAdapterSchema": "src/data/driver/memory.zod.ts#PersistenceAdapterSchema (const)", "PersistenceType": "src/data/driver/memory.zod.ts#PersistenceType (type)", "PersistenceTypeSchema": "src/data/driver/memory.zod.ts#PersistenceTypeSchema (const)", + "Picklist": "src/data/picklist.zod.ts#Picklist (type)", + "PicklistExtension": "src/data/picklist.zod.ts#PicklistExtension (type)", + "PicklistExtensionParsed": "src/data/picklist.zod.ts#PicklistExtensionParsed (type)", + "PicklistExtensionSchema": "src/data/picklist.zod.ts#PicklistExtensionSchema (const)", + "PicklistParsed": "src/data/picklist.zod.ts#PicklistParsed (type)", + "PicklistSchema": "src/data/picklist.zod.ts#PicklistSchema (const)", + "PicklistServedField": "src/data/picklist.zod.ts#PicklistServedField (type)", + "PicklistServedFieldParsed": "src/data/picklist.zod.ts#PicklistServedFieldParsed (type)", + "PicklistServedFieldSchema": "src/data/picklist.zod.ts#PicklistServedFieldSchema (const)", "PoolConfig": "src/data/driver.zod.ts#PoolConfig (type)", "PoolConfigParsed": "src/data/driver.zod.ts#PoolConfigParsed (type)", "PoolConfigSchema": "src/data/driver.zod.ts#PoolConfigSchema (const)", @@ -775,6 +784,7 @@ "defineHook": "src/data/hook.zod.ts#defineHook (function)", "defineMapping": "src/data/mapping.zod.ts#defineMapping (function)", "defineObjectExtension": "src/data/object.zod.ts#defineObjectExtension (function)", + "definePicklist": "src/data/picklist.zod.ts#definePicklist (function)", "defineSeed": "src/data/seed.zod.ts#defineSeed (function)", "deriveFieldGroupLayout": "src/data/field-group-layout.ts#deriveFieldGroupLayout (function)", "deriveRecordFlowSurface": "src/data/record-surface.ts#deriveRecordFlowSurface (function)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 1b8db1e9a91..211e36b2dd6 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -163,6 +163,7 @@ "defineObjectExtension": "src/data/object.zod.ts#defineObjectExtension (function)", "definePage": "src/ui/page.zod.ts#definePage (function)", "definePermissionSet": "src/security/permission.zod.ts#definePermissionSet (function)", + "definePicklist": "src/data/picklist.zod.ts#definePicklist (function)", "definePosition": "src/identity/position.zod.ts#definePosition (function)", "defineReport": "src/ui/report.zod.ts#defineReport (function)", "defineSharingRule": "src/security/sharing.zod.ts#defineSharingRule (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 9c06a6fdd0c..f6ced167812 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -476,6 +476,7 @@ "PageComponentLike": "src/system/i18n-resolver.ts#PageComponentLike (interface)", "PageLike": "src/system/i18n-resolver.ts#PageLike (interface)", "PageRegionLike": "src/system/i18n-resolver.ts#PageRegionLike (interface)", + "PicklistLike": "src/system/i18n-resolver.ts#PicklistLike (interface)", "Plan": "src/system/license.zod.ts#Plan (type)", "PlanParsed": "src/system/license.zod.ts#PlanParsed (type)", "PlanSchema": "src/system/license.zod.ts#PlanSchema (const)", @@ -811,6 +812,7 @@ "translateMetadataDocument": "src/system/i18n-resolver.ts#translateMetadataDocument (function)", "translateObject": "src/system/i18n-resolver.ts#translateObject (function)", "translatePage": "src/system/i18n-resolver.ts#translatePage (function)", + "translatePicklist": "src/system/i18n-resolver.ts#translatePicklist (function)", "translateView": "src/system/i18n-resolver.ts#translateView (function)", "validationMessageTranslationKey": "src/system/validation-message.ts#validationMessageTranslationKey (function)", "walkAddressedPageComponents": "src/system/i18n-resolver.ts#walkAddressedPageComponents (function)" diff --git a/packages/spec/json-schema.manifest/data.json b/packages/spec/json-schema.manifest/data.json index 53ebe1d6f17..43355db09f1 100644 --- a/packages/spec/json-schema.manifest/data.json +++ b/packages/spec/json-schema.manifest/data.json @@ -134,6 +134,9 @@ "data/ObjectRequiredPermissions", "data/PerOperationRequiredPermissions", "data/PersistenceType", + "data/Picklist", + "data/PicklistExtension", + "data/PicklistServedField", "data/PoolConfig", "data/PostgresConfig", "data/Query", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 661ad73fe0c..b717e9ff869 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -816,7 +816,7 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type RecordDetailView had been gating the History tab on it the whole time (#2707). 4. Add the type to `GOVERNED`; confirm the gate is green. -## Current state — 40 governed types (complete registry coverage) +## Current state — 41 governed types (complete registry coverage) > **This heading is now checked** (#7257). `check:liveness` reconciles the table > against `GOVERNED` in both directions — a governed type with no row fails, a row @@ -930,6 +930,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | email_template | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) | | job | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowOrgOverride` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) **#4667**: `id` REMOVED (row deleted, strict removal) — nothing read it and its own describe() ("defaults to `name` when omitted") advertised an identity override that never existed; `name` is the scheduling key, the sys_job row key and the JobExecution.jobId stamp, so two jobs differing only in `id` were one job. **#7131** (PR #7425) takes the remaining two: `label` and `description` re-grade `dead` → `live` under the 2026-08-10 maintainer ruling that **designer previews count as consumers** — objectui's `JobPreview` had been reading `d.label` and `d.description` and rendering them as the preview card's title and subtitle the whole time, so the old "no runtime consumer" was a true statement about the *scheduler* and a false one about the system. **This row now has zero dead and the ADR-0033 exemption is still in force**, which is worth saying out loud because it is the first row in this table where those two facts hold together: the keys are still docs-shaped, still deliberately KEPT, still not `authorWarn`'d, and enforce-or-remove still has nothing to chase here. What changed is only that the exemption no longer has to carry the verdict — the measurement does. | | mapping | seeded 2026-08-01 (#4488) at 8/11 live; **0 dead since #4509** retired the three that were not. The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. RETIRED 17.0.0: `extractQuery` (authorWarn — "for export only" promised an export path no exporter implements) + `errorPolicy`/`batchSize`, which were dead AND **unwarnable** (schema defaults materialize at parse, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule). That unwarnability is why they went out in the 17.0.0 window rather than after a deprecation cycle: removal was the only channel that could ever reach the author. Rows DELETED, not tombstoned — MappingSchema is strict, so the keys left the walked shape | +| picklist | seeded 2026-09-30 (#19518) — `PicklistSchema`, the shared option list select fields reference by name. Every key `planned`: the kind is registered with the spec layer ahead of its runtime reader by ruling, and the runtime layer (#19519 — load before `object`, additive `picklistExtensions` merge, resolve onto the served field) has not landed. `field.picklist` carries the same verdict with `authorWarn`, because until then a picklist-bound field is served with no options. | | seed | seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped | | translation | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 11 of 12 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead) — `flows` was the one that was not, and was `planned` at seeding. **#14253** added the twelfth, `datasets`, seeded LIVE and DRILLED (label / description / dimensions / measures) with its reader in the same change: `translateDataset` in the dispatch table, which is what `TRANSLATABLE_METADATA_TYPES` is derived from, so the REST boundary followed with nothing else to remember. The same change gave `objects.._views..bulkActions` and `objects.._validations..message` their first keys — both beneath the walk boundary, so neither adds a row here. Dead 1 = `validationMessages` (authorWarn) at seeding: nothing resolved it, and #3778's own legacy-key migration table steered `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape. **#4667**: `validationMessages` REMOVED (row deleted) — removed from the shared translationDataShape(), so it retired at BOTH doors at once, closing the item-only asymmetry #3778's original guard had. #3778's own `errors` guidance was rewritten in the same change: it had been steering authors INTO this dead group. ⚠️ **What that left behind is this table's own worked example of the defect it warns about** (#7377): the same commit that deleted the `validationMessages` row wrote a count column of `dead 2` beside a sentence that named exactly one dead key — and that one was the key it had just removed. The real two were `name` and `label`, which the cell never mentioned. Measured at that commit, not inferred: the ledger's dead set there is `{name, label}` and `validationMessages` is absent from `props`. The number was right and the prose was false, in the same cell, on the day it was written — which is why the counts are now generated and this cell holds prose only. **#7131** (PR #7425) resolves it: `name` and `label` re-grade `dead` → `live` under the designer-previews-count-as-consumers ruling (objectui `TranslationPreview.tsx:67` reads `label` first and falls back to `name`, both rendering at `:100`), so the dead set is empty and there is no dead-set sentence left to keep true. As on `job`, the ADR-0033 docs-shaped exemption is untouched — nothing about enforce-or-remove moved. **#19620** (ruling batch #210 item 2 letter B): `settings` row DELETED — the strict-delete route, because `TranslationItemSchema` no longer declares the key and refuses it by name (the item door now takes the per-app face, as the file door has since #15178). ⚠️ The deleted row read `live`, and that verdict was TRUE and stays true of the platform: its evidence read the SERVED tree, which the platform bundle feeds, so the deletion retires the key from the application-authored item and nothing else — the capability lives on `PlatformTranslationDataSchema`, outside this ledger. **#20296**: `flows` is now half-read. `flows.screens` re-graded `planned` → `live`: objectui's FlowRunner reads each screen's `title` and each field's `label` / `placeholder` at the `.objectui-sha` pin f8a9d0fb. `flows.label` stays `planned` because nothing reads it yet (#20318), and so does the container's `authorWarn`, whose `authorHint` now names the read half and the unread one. | | qa | seeded 2026-08-10 (#6247) — **not a metadata type**: `TestSuiteSchema` is the FILE surface of the shipped `os test` command (`qa/*.test.json`), governed through the same `SPEC_ONLY_SCHEMAS` override as `query`/`webhook`/`validation`. It is in the table as the clearest worked example of a **false `dead` measurement**: #6247 reported the whole domain declared-but-inert on a grep that scanned only `*Schema` identifiers, and every consumer here reads the **type** names (`QA.TestSuite`, `QA.TestStep`, `QA.TestAction`) — so an entire execution chain (core's `TestRunner` + `HttpTestAdapter`, published via `export * as QA`, driven by a documented CLI command) read as zero consumers, and a retire ruling was issued on it before being withdrawn. The `evidenceScope` table one section up says no amount of specifier matching is sufficient for a negative claim; this is the same lesson for **identifier** matching. What was really wrong was narrower and real: the type was the contract and the schema had no `parse` site, so the CLI's `JSON.parse(content) as QA.TestSuite` cast admitted anything — ENFORCED in the same change (`TestSuiteSchema.safeParse` at the load site, pinned). The seeding recorded Dead 5 — `name`, `scenarios.name`, `scenarios.description`, `scenarios.tags` and `scenarios.requires` — and #20289 (family `qa-runner`, verdict ENFORCE) made all FIVE live: the suite `name` heads the suite in `os test`'s report and is stamped as `suiteName` on every TestResult the suite produces; `scenarios.name` is printed beside the id and carried as `scenarioName`; `scenarios.description` is carried and printed under a failed scenario; `scenarios.tags` is read by `os test --tags` (any-of, exact; deselected scenarios are counted and never passed); and `scenarios.requires` is judged by the TestRunner before a scenario's first step — `params` against the environment of the process running `os test`, `services` against the target's discovery `services` (enabled and `available`) — so an unmet entry SKIPS the scenario with its reason, counted apart and never passed. `requires` was put to the maintainer first rather than guessed (ruled B): over HTTP no server surface lists the loaded plugins, so `requires.plugins` is a `retiredKey()` tombstone inside the block whose prescription names `requires.services`. It does not carry `authorWarn` and the omission is deliberate (`_authorWarnSkipped`): the lint walks stack **collections**, a QA suite is a loose file in no stack, so a warn flag here would emit nothing — a silent no-op inside the mechanism built to catch silent no-ops | diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 666d73d17f9..4bf3c828bc7 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -56,6 +56,13 @@ "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." }, + "picklist": { + "status": "planned", + "verifiedAt": "2026-09-30", + "authorWarn": true, + "authorHint": "A field that names a picklist is not yet resolved by the server: until picklist resolution ships, the field is served with no options, so its control offers nothing and a written value is not checked against the list. Keep inline `options` on fields that must work today.", + "note": "Declared with the spec layer (#19518); mutually exclusive with `options` at the schema door (FieldSchema superRefine). The reader is the runtime layer (#19519), which resolves the reference onto the served field's `options` — not landed, so `planned`. `authorWarn` because the interim is the misleading shape: the key parses and publishes, and the served field carries no options until #19519. Flip to `live` against its resolver." + }, "deleteBehavior": { "status": "live", "evidence": "packages/objectql/src/engine.ts", diff --git a/packages/spec/liveness/picklist.json b/packages/spec/liveness/picklist.json new file mode 100644 index 00000000000..099b17d352d --- /dev/null +++ b/packages/spec/liveness/picklist.json @@ -0,0 +1,65 @@ +{ + "type": "picklist", + "_note": "PicklistSchema (`data/picklist.zod.ts`) — a shared option list that select fields reference by name (`Field.select({ picklist })`). Seeded 2026-09-30 with the spec layer (#19518), every key `planned`: the kind is registered ahead of its runtime reader by ruling (spec layer first, runtime layer #19519 Blocked-by it). The ADR-0010 envelope keys carry the same `null` verdict as `capability`: loader-stamped, not authored.", + "props": { + "name": { + "status": "planned", + "verifiedAt": "2026-09-30", + "note": "The handle a field's `picklist` names. Declared with the spec layer of the picklist kind (#19518); its runtime reader is the runtime layer (#19519: load before `object`, additive merge of `picklistExtensions`, resolve onto the served field, write validation against the resolved set), which has NOT landed. No code outside `packages/spec` reads a picklist yet, so this is `planned`, not `live`. Re-verify to `live` against #19519's resolver." + }, + "label": { + "status": "planned", + "verifiedAt": "2026-09-30", + "note": "Display label of the list (the Studio picklist page is a later phase). Declared with the spec layer of the picklist kind (#19518); its runtime reader is the runtime layer (#19519: load before `object`, additive merge of `picklistExtensions`, resolve onto the served field, write validation against the resolved set), which has NOT landed. No code outside `packages/spec` reads a picklist yet, so this is `planned`, not `live`. Re-verify to `live` against #19519's resolver." + }, + "description": { + "status": "planned", + "verifiedAt": "2026-09-30", + "note": "Author-facing summary of what the list enumerates. Declared with the spec layer of the picklist kind (#19518); its runtime reader is the runtime layer (#19519: load before `object`, additive merge of `picklistExtensions`, resolve onto the served field, write validation against the resolved set), which has NOT landed. No code outside `packages/spec` reads a picklist yet, so this is `planned`, not `live`. Re-verify to `live` against #19519's resolver." + }, + "options": { + "status": "planned", + "verifiedAt": "2026-09-30", + "note": "The options every referencing field is served, resolved onto the field (`PicklistServedFieldSchema`). Declared with the spec layer of the picklist kind (#19518); its runtime reader is the runtime layer (#19519: load before `object`, additive merge of `picklistExtensions`, resolve onto the served field, write validation against the resolved set), which has NOT landed. No code outside `packages/spec` reads a picklist yet, so this is `planned`, not `live`. Re-verify to `live` against #19519's resolver.", + "children": { + "label": { + "status": "planned", + "verifiedAt": "2026-09-30", + "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + }, + "value": { + "status": "planned", + "verifiedAt": "2026-09-30", + "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + }, + "description": { + "status": "planned", + "verifiedAt": "2026-09-30", + "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + }, + "color": { + "status": "planned", + "verifiedAt": "2026-09-30", + "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + }, + "default": { + "status": "planned", + "verifiedAt": "2026-09-30", + "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + }, + "visibleWhen": { + "status": "planned", + "verifiedAt": "2026-09-30", + "note": "A picklist option — the field option shape reused verbatim (`SelectOptionSchema`). Read, like the list itself, by the #19519 resolver once it lands; it reaches every consumer through the referencing field's resolved `options`, where the same key is `live` on `field.json`." + } + } + }, + "_lock": null, + "_lockReason": null, + "_lockSource": null, + "_provenance": null, + "_packageId": null, + "_packageVersion": null, + "_lockDocsUrl": null + } +} diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 1261ef56763..83feef47783 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` | 91 | 0 | 0 | 1 | 1 | 93 | +| `field` | 91 | 0 | 0 | 1 | 2 | 94 | diff --git a/packages/spec/liveness/state-counts/picklist.md b/packages/spec/liveness/state-counts/picklist.md new file mode 100644 index 00000000000..b9b5325e9d0 --- /dev/null +++ b/packages/spec/liveness/state-counts/picklist.md @@ -0,0 +1,15 @@ + + + +# `picklist` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `picklist` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `picklist` | 7 | 0 | 0 | 0 | 9 | 16 | diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index 41392111e07..d28b11bf5f8 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 23 | 0 | 0 | 0 | 1 | 24 | +| `translation` | 23 | 0 | 0 | 0 | 3 | 26 | diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index ee8f4eb4721..65d8598005a 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -30,6 +30,26 @@ "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupObjectField (`objects..label` / `pluralLabel` / `description`); packages/spec/src/system/i18n-resolver.ts#lookupObjectFieldAttr (`objects..fields..`); packages/spec/src/system/i18n-resolver.ts#resolveViewLabel (`objects.._views..label`); packages/spec/src/system/i18n-resolver.ts#lookupActionField (`objects.._actions`, checked BEFORE globalActions); packages/spec/src/system/i18n-resolver.ts#lookupTabLabel (`objects.._tabs`); objectui @940ba24: packages/i18n/src/useObjectLabel.ts:397-400", "note": "the largest group, fully live: label/pluralLabel/description (translateObject), fields.{label,help,placeholder,options}, _views (resolveViewLabel + empty-state copy), _actions (label/confirmText/successMessage/params/resultDialog — object-scoped first, then globalActions fallback), _sections (objectui record:details section labels). Served through REST translateMetaItem(s) and the /api/v1/i18n endpoints; objectui re-resolves client-side via the spec-translations transform. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — this entry named SIX positions in i18n-resolver.ts and only the first, `:735`, was parsable as a citation at all; it had rotted onto `widgets?: WidgetLike[]` in the `DashboardLike` INTERFACE. The other five (`:751`, `:159`, `:197`, `:873`, `:900`) were bare line suffixes with no path in front of them, so they were never resolved, bounded or key-checked by anything — the largest instance in this batch of the class `flow.status` and `dashboard.widgets.suppressWarnings` also carried. The five anchors above are the five distinct group readers, named. Re-closed by hand against 8cb96ec41." }, + "picklists": { + "status": "planned", + "verifiedAt": "2026-09-30", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translatePicklist (a served `picklist` item: `picklists..label` and `.options.`, registered in METADATA_DOCUMENT_TRANSLATORS); packages/spec/src/system/i18n-resolver.ts#translateObject (a picklist-bound field inherits `picklists..options.` after its own field-level entry)", + "note": "Declared with the spec layer of the picklist kind (#19518), resolvers in the same change. `planned`, not `live`, because the PRODUCER half is missing: nothing serves a picklist item or a picklist-bound field with its options resolved until the runtime layer (#19519) lands, so both resolvers are reachable only from tests today. Flip to `live` when #19519's resolved serve reaches them.", + "children": { + "label": { + "status": "planned", + "verifiedAt": "2026-09-30", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupPicklistLabel (`picklists..label`, read by translatePicklist)", + "note": "The list's own display name — the served picklist item, which nothing serves until #19519." + }, + "options": { + "status": "planned", + "verifiedAt": "2026-09-30", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupPicklistOption (`picklists..options.`, read by translatePicklist and by translateObject for a field whose `picklist` names the list)", + "note": "Option labels, translated once and inherited by every referencing field; a field-level `objects..fields..options` entry stays the more specific and wins. Reached once #19519 serves the resolved options." + } + } + }, "apps": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index d616581150e..828742e0018 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (203 schemas) +## 3. Schema Inventory by Domain (204 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -87,7 +87,7 @@ here is verifiable from the installed package. |--------|-------|-------------| | system | 34 | Auth, Cache, Compliance, Dev Login, Encryption, HTTP Server, License, Logging, Metrics | | kernel | 31 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry | -| data | 30 | Object, Field, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | +| data | 31 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | | api | 31 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage) | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | | automation | 14 | Flow, Approval, BPMN Interop, Control Flow, State Machine, Webhook, Schedule Organization | diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 03447962b60..0ea9e57c68c 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -276,7 +276,7 @@ const ledgerRoot = ledgerRootArg // Governed metadata types, rolled out highest-frequency / highest-risk first. // (`query` is not a metadata type — see SPEC_ONLY_SCHEMAS below.) -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'seed', 'translation', 'validation', 'api', 'capability', 'qa', 'manifest', 'crud_endpoints', 'metadata_endpoints', 'batch_endpoints', 'route_generation', 'rest_api', 'realtime_subscription', 'sharing_rule', 'connector', 'analytics_cube']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'picklist', 'seed', 'translation', 'validation', 'api', 'capability', 'qa', 'manifest', 'crud_endpoints', 'metadata_endpoints', 'batch_endpoints', 'route_generation', 'rest_api', 'realtime_subscription', 'sharing_rule', 'connector', 'analytics_cube']; // Authorable metadata types that are NOT yet governed — the coverage ratchet. // diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index fb2f1519d26..f2003089a31 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -10,7 +10,7 @@ import type { KeySetGuidance } from '../shared/suggestions.zod'; // `shared/visibility.ts`, which imports nothing at runtime. import { SELECT_OPTION_EDITABILITY_GUIDANCE } from '../shared/editability-boundary'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { SystemIdentifierSchema } from '../shared/identifiers.zod'; +import { SnakeCaseIdentifierSchema, SystemIdentifierSchema } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { FilterConditionSchema } from './filter.zod'; import { FIELD_KEY_GUIDANCE } from './authoring-key-lint'; @@ -27,7 +27,7 @@ import { discriminateDefaultValueShape, suggestDefaultValueToken, } from './default-value-shape'; -import { AddressSchema, FILE_REFERENCE_TYPES, MULTI_CAPABLE_TYPES, MULTI_OPTION_TYPES, REFERENCE_VALUE_TYPES } from './field-value.zod'; +import { AddressSchema, FILE_REFERENCE_TYPES, MULTI_CAPABLE_TYPES, MULTI_OPTION_TYPES, REFERENCE_VALUE_TYPES, SINGLE_OPTION_TYPES } from './field-value.zod'; import { ValueDomainSchema } from '../shared/value-domain.zod'; /** @@ -1037,7 +1037,10 @@ export const FieldSchema = lazySchema(() => { // WRITE contract, and ADR-0113 moved neither. isRequired: 'required', mandatory: 'required', isUnique: 'unique', - values: 'options', choices: 'options', picklist: 'options', selectOptions: 'options', + // `picklist` is not here: it is a declared key — the reference to a shared + // option list, mutually exclusive with `options` (see the key below). + values: 'options', choices: 'options', selectOptions: 'options', + valueSet: 'picklist', globalValueSet: 'picklist', optionSet: 'picklist', relatedTo: 'reference', referenceTo: 'reference', target: 'reference', targetObject: 'reference', lookupObject: 'reference', onDelete: 'deleteBehavior', deleteRule: 'deleteBehavior', cascade: 'deleteBehavior', formula: 'expression', calculation: 'expression', compute: 'expression', @@ -1339,6 +1342,28 @@ export const FieldSchema = lazySchema(() => { /** Selection Options */ options: z.array(SelectOptionSchema).optional().describe('Static options for select/multiselect'), + /** + * Reference to a shared option list — a `picklist` item, by name — in place + * of inline `options` (`data/picklist.zod.ts`). + * + * Mutually exclusive with `options`, refused at this door when both are + * written: the field's options come from exactly one source. Valid only on + * the option types (`select`, `radio`, `multiselect`, `checkboxes`, + * `tags`), the types whose value is an option code. + * + * The reference is resolved on the SERVER: the field a client reads from + * the object read exits carries the resolved `options` next to this key + * (`PicklistServedFieldSchema`), so renderers, the record validator + * and filter pickers read `options` exactly as they do for an inline list. + * Option labels translate under `picklists..options.`, which + * every referencing field inherits. + */ + picklist: SnakeCaseIdentifierSchema.optional().describe( + 'Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. ' + + 'Option types only (select, radio, multiselect, checkboxes, tags). The server resolves the ' + + 'reference: the field clients read carries the resolved `options`.', + ), + /** * Relationship Config * @@ -2104,6 +2129,35 @@ export const FieldSchema = lazySchema(() => { }); } + // A field's options come from exactly ONE source: inline `options`, or the + // shared list `picklist` names (`data/picklist.zod.ts`). Both is refused — + // two sources, one of them silently ignored. (Neither, on a single-choice + // type, stays the error-severity `FIELD_CHOICE_WITHOUT_OPTIONS` finding of + // `kernel/functional-completeness.ts`, whose prescription names both.) + if (field.picklist !== undefined) { + if (!SINGLE_OPTION_TYPES.has(field.type) && !MULTI_OPTION_TYPES.has(field.type)) { + ctx.addIssue({ + code: 'custom', + path: ['picklist'], + message: + `\`picklist\` is only valid on an option type — select, radio, multiselect, checkboxes or ` + + `tags (this field is \`${field.type}\`): it names the shared list the field's value is ` + + 'chosen from, and this type stores no option code. Change the type, or remove `picklist`.', + }); + } + if (field.options !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['options'], + message: + '`picklist` and `options` cannot both be declared — a field takes its options from exactly ' + + "one source. Keep `picklist: ''` and delete `options`: the shared list supplies them " + + '(to offer a new value, add it to the picklist, or through `picklistExtensions` when another ' + + 'package owns it). Or delete `picklist` to keep an inline list of this field\'s own.', + }); + } + } + // 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 @@ -2556,6 +2610,53 @@ export type CurrencyValue = z.input; */ export type FieldInput = Omit, 'type'>; +/** `Field.select` with inline options — the array or `{ options }` forms. */ +function selectWithOptions(optionsOrConfig: Array | Omit & { options: Array }, config?: FieldInput) { + // Helper function to convert string to lowercase snake_case + const toSnakeCase = (str: string): string => { + return str + .toLowerCase() + .replace(/\s+/g, '_') // Replace spaces with underscores + .replace(/[^a-z0-9_]/g, ''); // Remove invalid characters (keeping underscores only) + }; + + // Support both old and new signatures: + // Old: Field.select(['a', 'b'], { label: 'X' }) + // New: Field.select({ options: [{label: 'A', value: 'a'}], label: 'X' }) + let options: SelectOption[]; + let finalConfig: FieldInput; + + if (Array.isArray(optionsOrConfig)) { + // Old signature: array as first param + options = optionsOrConfig.map(o => + typeof o === 'string' + ? { label: o, value: toSnakeCase(o) } // Auto-convert string to snake_case + : { ...o, value: o.value.toLowerCase() } // Ensure value is lowercase + ); + finalConfig = config || {}; + } else { + // New signature: config object with options + options = (optionsOrConfig.options || []).map(o => + typeof o === 'string' + ? { label: o, value: toSnakeCase(o) } // Auto-convert string to snake_case + : { ...o, value: o.value.toLowerCase() } // Ensure value is lowercase + ); + // Remove options from config to avoid confusion + const { options: _, ...restConfig } = optionsOrConfig; + finalConfig = restConfig; + } + + return { type: 'select', options, ...finalConfig } as const; +} + +/** + * `Field.select` bound to a shared picklist — no `options` of its own: the + * picklist supplies them, and `FieldSchema` refuses the two together. + */ +function selectFromPicklist(config: C) { + return { type: 'select', ...config } as const; +} + export const Field = { text: (config: FieldInput = {}) => ({ type: 'text', ...config } as const), textarea: (config: FieldInput = {}) => ({ type: 'textarea', ...config } as const), @@ -2623,44 +2724,18 @@ export const Field = { * @example Multi-word values - converts to snake_case * Field.select(['In Progress', 'Closed Won'], { label: 'Status' }) * // Results in: [{ label: 'In Progress', value: 'in_progress' }, { label: 'Closed Won', value: 'closed_won' }] + * + * @example Shared picklist — the options come from the named `picklist` item + * Field.select({ picklist: 'industry', label: 'Industry' }) + * // Results in: { type: 'select', picklist: 'industry', label: 'Industry' } — no `options` */ - select: (optionsOrConfig: SelectOption[] | string[] | FieldInput & { options: SelectOption[] | string[] }, config?: FieldInput) => { - // Helper function to convert string to lowercase snake_case - const toSnakeCase = (str: string): string => { - return str - .toLowerCase() - .replace(/\s+/g, '_') // Replace spaces with underscores - .replace(/[^a-z0-9_]/g, ''); // Remove invalid characters (keeping underscores only) - }; - - // Support both old and new signatures: - // Old: Field.select(['a', 'b'], { label: 'X' }) - // New: Field.select({ options: [{label: 'A', value: 'a'}], label: 'X' }) - let options: SelectOption[]; - let finalConfig: FieldInput; - - if (Array.isArray(optionsOrConfig)) { - // Old signature: array as first param - options = optionsOrConfig.map(o => - typeof o === 'string' - ? { label: o, value: toSnakeCase(o) } // Auto-convert string to snake_case - : { ...o, value: o.value.toLowerCase() } // Ensure value is lowercase - ); - finalConfig = config || {}; - } else { - // New signature: config object with options - options = (optionsOrConfig.options || []).map(o => - typeof o === 'string' - ? { label: o, value: toSnakeCase(o) } // Auto-convert string to snake_case - : { ...o, value: o.value.toLowerCase() } // Ensure value is lowercase - ); - // Remove options from config to avoid confusion - const { options: _, ...restConfig } = optionsOrConfig; - finalConfig = restConfig; - } - - return { type: 'select', options, ...finalConfig } as const; - }, + select: ((optionsOrConfig: unknown, config?: FieldInput) => + !Array.isArray(optionsOrConfig) + && typeof (optionsOrConfig as { picklist?: unknown }).picklist === 'string' + && (optionsOrConfig as { options?: unknown }).options === undefined + ? selectFromPicklist(optionsOrConfig as FieldInput & { picklist: string; options?: undefined }) + : selectWithOptions(optionsOrConfig as Parameters[0], config) + ) as typeof selectWithOptions & typeof selectFromPicklist, /** diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index d3125954577..f6f8ee7b258 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -205,6 +205,7 @@ export * from './hook-api'; // flag per event so "not yet" can never read as "yes". export * from './bulk-write-hook-conformance'; export * from './mapping.zod'; +export * from './picklist.zod'; export * from './data-engine.zod'; export * from './driver.zod'; export * from './driver-sql.zod'; diff --git a/packages/spec/src/data/picklist.test.ts b/packages/spec/src/data/picklist.test.ts new file mode 100644 index 00000000000..1591c01f75c --- /dev/null +++ b/packages/spec/src/data/picklist.test.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + PicklistSchema, + PicklistExtensionSchema, + PicklistServedFieldSchema, + definePicklist, +} from './picklist.zod'; +import { Field, FieldSchema } from './field.zod'; +import { checkFieldCompleteness } from '../kernel/functional-completeness'; +import { + DEFAULT_METADATA_TYPE_REGISTRY, + MetadataTypeSchema, +} from '../kernel/metadata-plugin.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { PLURAL_TO_SINGULAR } from '../meta-spelling/manifest-collection-spelling'; +import { ObjectStackDefinitionSchema, composeStacks, defineStack } from '../stack.zod'; +import { TranslationDataSchema } from '../system/translation.zod'; +import { + TRANSLATABLE_METADATA_TYPES, + translateObject, + translatePicklist, +} from '../system/i18n-resolver'; + +const INDUSTRY = { + name: 'industry', + label: 'Industry', + options: [ + { label: 'Technology', value: 'technology' }, + { label: 'Finance', value: 'finance' }, + ], +}; + +/** The issues a failed parse carries, as `path.join('.')` → issue. */ +function issuesByPath(result: { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string; message: string }> } }) { + expect(result.success).toBe(false); + return new Map(result.error!.issues.map((i) => [i.path.join('.'), i])); +} + +describe('picklist — the kind', () => { + it('parses the ruled shape { name, label, description?, options: SelectOption[] }', () => { + const parsed = PicklistSchema.parse({ ...INDUSTRY, description: 'What a company does' }); + expect(parsed.name).toBe('industry'); + expect(parsed.options.map((o) => o.value)).toEqual(['technology', 'finance']); + expect(definePicklist(INDUSTRY).label).toBe('Industry'); + }); + + it('takes the field option shape verbatim — color / default / visibleWhen ride along', () => { + const parsed = PicklistSchema.parse({ + ...INDUSTRY, + options: [{ label: 'Tech', value: 'technology', color: '#0af', default: true, visibleWhen: "record.region == 'emea'" }], + }); + expect(parsed.options[0].color).toBe('#0af'); + expect(parsed.options[0].default).toBe(true); + }); + + it('refuses an empty list at `options`', () => { + const issues = issuesByPath(PicklistSchema.safeParse({ ...INDUSTRY, options: [] })); + expect(issues.get('options')?.code).toBe('too_small'); + }); + + it('refuses a non-snake_case name at `name`', () => { + const issues = issuesByPath(PicklistSchema.safeParse({ ...INDUSTRY, name: 'Industry' })); + expect(issues.has('name')).toBe(true); + }); + + it('is a closed shape — an undeclared key is refused, not stripped', () => { + const issues = issuesByPath(PicklistSchema.safeParse({ ...INDUSTRY, restricted: true })); + expect([...issues.values()].some((i) => i.code === 'unrecognized_keys')).toBe(true); + }); +}); + +describe('picklistExtensions — additive only', () => { + it('parses { extend, options }', () => { + const parsed = PicklistExtensionSchema.parse({ + extend: 'industry', + options: [{ label: 'Healthcare', value: 'healthcare' }], + }); + expect(parsed.extend).toBe('industry'); + }); + + it('declares no key that removes or relabels — `remove` is refused as an unknown key', () => { + const issues = issuesByPath(PicklistExtensionSchema.safeParse({ + extend: 'industry', + options: [{ label: 'Healthcare', value: 'healthcare' }], + remove: ['finance'], + })); + const unknown = [...issues.values()].find((i) => i.code === 'unrecognized_keys'); + expect(unknown?.message).toContain('remove'); + }); + + it('refuses an extension that adds nothing', () => { + const issues = issuesByPath(PicklistExtensionSchema.safeParse({ extend: 'industry', options: [] })); + expect(issues.get('options')?.code).toBe('too_small'); + }); +}); + +describe('Field `picklist` — mutually exclusive with `options` at the schema door', () => { + it('a select that names a picklist parses, with no options of its own', () => { + const parsed = FieldSchema.parse({ name: 'industry', label: 'Industry', type: 'select', picklist: 'industry' }); + expect(parsed.picklist).toBe('industry'); + expect(parsed.options).toBeUndefined(); + }); + + it('`Field.select({ picklist })` builds the reference form and it parses', () => { + const def = Field.select({ picklist: 'industry', label: 'Industry' }); + expect(def).toEqual({ type: 'select', picklist: 'industry', label: 'Industry' }); + expect(FieldSchema.safeParse(def).success).toBe(true); + }); + + it('`picklist` + `options` together is refused at `options`', () => { + const issues = issuesByPath(FieldSchema.safeParse({ + name: 'industry', label: 'Industry', type: 'select', + picklist: 'industry', options: [{ label: 'Tech', value: 'technology' }], + })); + const both = issues.get('options'); + expect(both?.code).toBe('custom'); + expect(both?.message).toContain('`picklist`'); + }); + + it('`Field.select` handed both keeps both, so the pair still reaches the refusal', () => { + const def = Field.select({ + picklist: 'industry', label: 'Industry', options: [{ label: 'Tech', value: 'technology' }], + } as Parameters[0]); + expect(FieldSchema.safeParse(def).success).toBe(false); + }); + + it.each(['select', 'radio'] as const)('neither on a %s is the completeness gate\'s error, not a parse refusal', (type) => { + // The schema door refuses only the pair. An optionless single-choice field + // keeps the verdict it had before the kind existed: parse-legal, and an + // error-severity `field/choice-without-options` finding at the author-time + // and registry gates — which a `picklist` reference now satisfies. + expect(FieldSchema.safeParse({ name: 'f', label: 'F', type }).success).toBe(true); + expect(checkFieldCompleteness({ type }).map((f) => [f.rule, f.severity])) + .toEqual([['field/choice-without-options', 'error']]); + expect(checkFieldCompleteness({ type, picklist: 'industry' })).toEqual([]); + }); + + it.each(['select', 'radio', 'multiselect', 'checkboxes', 'tags'] as const)('`picklist` is accepted on the option type %s', (type) => { + expect(FieldSchema.safeParse({ name: 'f', label: 'F', type, picklist: 'industry' }).success).toBe(true); + }); + + it.each(['text', 'lookup', 'number'] as const)('`picklist` on the non-option type %s is refused at `picklist`', (type) => { + const fixture = type === 'lookup' + ? { name: 'f', label: 'F', type, reference: 'account', picklist: 'industry' } + : { name: 'f', label: 'F', type, picklist: 'industry' }; + const issues = issuesByPath(FieldSchema.safeParse(fixture)); + expect(issues.get('picklist')?.code).toBe('custom'); + }); + +}); + +describe('the served shape — `options` resolved, `picklist` kept', () => { + const served = { + name: 'industry', label: 'Industry', type: 'select', picklist: 'industry', + options: [...INDUSTRY.options, { label: 'Healthcare', value: 'healthcare' }], + }; + + it('a served picklist-bound field carries the resolved options and passes the rest through', () => { + const parsed = PicklistServedFieldSchema.parse(served) as Record; + expect(parsed.picklist).toBe('industry'); + expect((parsed.options as Array<{ value: string }>).map((o) => o.value)) + .toEqual(['technology', 'finance', 'healthcare']); + expect(parsed.label).toBe('Industry'); + }); + + it('a served field with the reference unresolved (no options) breaks the contract', () => { + const { options: _omit, ...unresolved } = served; + const issues = issuesByPath(PicklistServedFieldSchema.safeParse(unresolved)); + expect(issues.has('options')).toBe(true); + }); + + it('the served form is not an authoring input — the field door refuses the pair', () => { + expect(FieldSchema.safeParse(served).success).toBe(false); + }); +}); + +describe('picklist — a registered kind and a stack collection', () => { + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'picklist'); + const objectEntry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'object'); + + it('is a member of the kind enum and resolves its schema', () => { + expect(MetadataTypeSchema.safeParse('picklist').success).toBe(true); + expect(getMetadataTypeSchema('picklist')).toBe(PicklistSchema); + }); + + it('loads before `object`, whose fields reference it', () => { + expect(entry).toBeDefined(); + expect(entry!.loadOrder).toBeLessThan(objectEntry!.loadOrder); + }); + + it('is package-owned: no runtime create, no per-org overlay; `*.picklist.ts` is the prescription', () => { + expect(entry!.allowRuntimeCreate).toBe(false); + expect(entry!.allowOrgOverride).toBe(false); + expect(entry!.filePatterns[0]).toBe('**/*.picklist.ts'); + }); + + it('`picklists` folds to the kind name', () => { + expect(PLURAL_TO_SINGULAR.picklists).toBe('picklist'); + }); + + it('a stack declares picklists, extensions, and a field that references one', () => { + const stack = ObjectStackDefinitionSchema.parse({ + manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app' }, + picklists: [INDUSTRY], + picklistExtensions: [{ extend: 'industry', options: [{ label: 'Healthcare', value: 'healthcare' }] }], + objects: [{ + name: 'account', + label: 'Account', + fields: { industry: Field.select({ picklist: 'industry', label: 'Industry' }) }, + }], + }); + expect(stack.picklists?.[0].name).toBe('industry'); + expect(stack.picklistExtensions?.[0].extend).toBe('industry'); + }); + + it('composing two stacks concatenates both collections', () => { + const a = defineStack({ picklists: [INDUSTRY] }, { strict: false }); + const b = defineStack({ + picklists: [{ ...INDUSTRY, name: 'region', label: 'Region' }], + picklistExtensions: [{ extend: 'industry', options: [{ label: 'Retail', value: 'retail' }] }], + }, { strict: false }); + const composed = composeStacks([a, b]); + expect(composed.picklists?.map((p) => p.name)).toEqual(['industry', 'region']); + expect(composed.picklistExtensions).toHaveLength(1); + }); +}); + +describe('the translation face — `picklists.`', () => { + const bundle = { + 'zh-CN': { + picklists: { + industry: { label: '行业', options: { technology: '科技', finance: '金融' } }, + }, + objects: { + lead: { fields: { industry: { options: { finance: '金融服务' } } } }, + }, + }, + }; + + it('parses { label?, options: { value: label } }', () => { + expect(TranslationDataSchema.safeParse(bundle['zh-CN']).success).toBe(true); + expect(TranslationDataSchema.safeParse({ picklists: { industry: { options: { finance: '金融' } } } }).success).toBe(true); + }); + + it('a field that references the picklist inherits its option labels', () => { + const served = translateObject({ + name: 'account', + fields: { + industry: { type: 'select', picklist: 'industry', options: INDUSTRY.options }, + }, + }, bundle, { locale: 'zh-CN' }) as { fields: Record }> }; + expect(served.fields.industry.options.map((o) => o.label)).toEqual(['科技', '金融']); + }); + + it('a field-level option label is the more specific and wins', () => { + const served = translateObject({ + name: 'lead', + fields: { + industry: { type: 'select', picklist: 'industry', options: INDUSTRY.options }, + }, + }, bundle, { locale: 'zh-CN' }) as { fields: Record }> }; + expect(served.fields.industry.options.map((o) => o.label)).toEqual(['科技', '金融服务']); + }); + + it('an inline-options field does not read the picklist group', () => { + const served = translateObject({ + name: 'account', + fields: { industry: { type: 'select', options: INDUSTRY.options } }, + }, bundle, { locale: 'zh-CN' }) as { fields: Record }> }; + expect(served.fields.industry.options.map((o) => o.label)).toEqual(['Technology', 'Finance']); + }); + + it('a served picklist item translates its label and options, and is a translatable type', () => { + const doc = translatePicklist(INDUSTRY, bundle, { locale: 'zh-CN' }); + expect(doc.label).toBe('行业'); + expect(doc.options.map((o) => o.label)).toEqual(['科技', '金融']); + expect(TRANSLATABLE_METADATA_TYPES.has('picklist')).toBe(true); + }); +}); diff --git a/packages/spec/src/data/picklist.zod.ts b/packages/spec/src/data/picklist.zod.ts new file mode 100644 index 00000000000..8cd2e97508a --- /dev/null +++ b/packages/spec/src/data/picklist.zod.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One list of select options that several fields on several objects use, + * instead of an options array copied into each field (Salesforce's Global + * Value Set, Dataverse's global choice). A field REFERENCES it with + * `picklist: ''` in place of `options` — `Field.select({ picklist: + * 'industry' })` — and the two are mutually exclusive at the field's schema + * door (see `FieldSchema.picklist`). + * + * Three shapes live here, one per position the list appears in: + * + * - {@link PicklistSchema} — the kind itself: `{ name, label, description?, + * options }`, authored in a package (`*.picklist.ts`, or + * `defineStack({ picklists })`). `options` is the field option shape + * ({@link SelectOptionSchema}), reused verbatim — there is no second option + * shape to learn. + * - {@link PicklistExtensionSchema} — `defineStack({ picklistExtensions })`, + * the `objectExtensions` idiom: another package ADDS options to a picklist + * it does not own. Additive only — removing or renaming a value stays with + * the owning package, so the shape has no key for either. + * - {@link PicklistServedFieldSchema} — the served form of a picklist-bound + * field: what a client reads from the object read exits once the reference + * is resolved. + * + * Package-owned: the registry entry (`kernel/metadata-plugin.zod.ts`) takes + * no runtime create and no per-organization overlay. An organization-level + * overlay that appends values is a later phase with its own admission, and + * is not declared here. + */ + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; +import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +import { SelectOptionSchema } from './field.zod'; + +const PICKLIST_HISTORY = + 'Until this shape was closed these would have been dropped silently — the list still ' + + 'registered, minus whatever the key was meant to carry.'; + +/** + * The shared option list itself. + * + * `name` is the handle a field's `picklist` names, so it follows the + * machine-name rule every metadata name follows (lowercase snake_case). + * `options` needs at least one entry: a list with nothing in it offers every + * field that references it nothing to choose. + * + * @example + * ```ts + * // src/picklists/industry.picklist.ts + * export default definePicklist({ + * name: 'industry', + * label: 'Industry', + * options: [ + * { label: 'Technology', value: 'technology' }, + * { label: 'Finance', value: 'finance' }, + * ], + * }); + * ``` + */ +export const PicklistSchema = lazySchema(() => strictObject({ + surface: 'this picklist', + history: PICKLIST_HISTORY, + aliases: { + values: 'options', choices: 'options', items: 'options', + title: 'label', displayName: 'label', + }, +}, { + name: SnakeCaseIdentifierSchema.describe( + "Picklist name (lowercase snake_case) — what a field's `picklist` names", + ), + label: z.string().describe('Display label of the list itself'), + description: z.string().optional().describe('What the list enumerates, for authors choosing one'), + options: z.array(SelectOptionSchema).min(1, { + message: + 'A picklist needs at least one option — an empty list offers every field that references ' + + 'it nothing to choose. Add `{ label, value }` entries.', + }).describe( + 'The options every referencing field offers — the field option shape (`label`, `value`, ' + + '`color`, `default`, `description`, `visibleWhen`), reused verbatim', + ), + + // ADR-0010 runtime protection envelope — stamped by the loader on every + // registered kind, never authored. + ...MetadataProtectionFields, +}).describe('A shared option list that select fields reference by name instead of copying options')); + +export type Picklist = z.input; +/** Post-parse shape of {@link Picklist} — defaults applied, transforms run (ADR-0122). */ +export type PicklistParsed = z.infer; + +/** + * Package-level extension of a picklist another package owns — the + * `objectExtensions` idiom applied to a list. + * + * ADDITIVE ONLY: an extension appends options; it cannot remove, rename or + * relabel one the owning package declared, and the shape declares no key + * that could ask to. A value that must go away is the owning package's + * change to make. + * + * @example + * ```ts + * defineStack({ + * picklistExtensions: [{ + * extend: 'industry', + * options: [{ label: 'Healthcare', value: 'healthcare' }], + * }], + * }); + * ``` + */ +export const PicklistExtensionSchema = lazySchema(() => strictObject({ + surface: 'this picklist extension', + history: PICKLIST_HISTORY, + aliases: { + picklist: 'extend', target: 'extend', name: 'extend', extends: 'extend', + values: 'options', choices: 'options', add: 'options', + }, + guidance: { + remove: + 'a picklist extension is additive only — it can add options, never remove one. Removing ' + + 'a value is a change to the owning package\'s picklist.', + label: + 'a picklist extension cannot relabel the list — the label belongs to the owning ' + + 'package\'s picklist. Translate it under `picklists..label` instead.', + }, +}, { + extend: SnakeCaseIdentifierSchema.describe('Name of the picklist (owned by another package) to add options to'), + options: z.array(SelectOptionSchema).min(1, { + message: 'A picklist extension adds at least one option — an empty `options` adds nothing.', + }).describe('Options appended to the target picklist (additive only)'), +}).describe("Options added to a picklist owned by another package (additive only — the `objectExtensions` idiom)")); + +export type PicklistExtension = z.input; +/** Post-parse shape of {@link PicklistExtension} — defaults applied, transforms run (ADR-0122). */ +export type PicklistExtensionParsed = z.infer; + +/** + * The SERVED form of a picklist-bound field — the contract the object read + * exits owe a client. + * + * A field authors `picklist: ''` instead of `options`. What a client + * receives for that field carries both: `options`, RESOLVED — the picklist's + * options together with the options its `picklistExtensions` add — and + * `picklist`, still naming the list they came from. Every consumer that reads + * `field.options` today (renderers, the record validator, filter pickers, + * import coercion) therefore reads the key it always has, and nothing a + * client does changes for a picklist-bound field. + * + * This is a SERVED shape, never an authoring one: `FieldSchema` refuses + * `picklist` together with `options`, so a served field sent back through an + * authoring door is refused with the prescription to drop `options`. + * + * Only the two keys this contract adds are declared; every other key of the + * served field is `FieldSchema`'s and passes through untouched. + */ +export const PicklistServedFieldSchema = lazySchema(() => z.looseObject({ + picklist: SnakeCaseIdentifierSchema.describe('The picklist the field references, as authored'), + options: z.array(SelectOptionSchema).min(1, { + message: + 'A served picklist-bound field carries its RESOLVED options — the reference is resolved ' + + 'before the field is served, so an empty or absent `options` means it was not.', + }).describe("The picklist's options resolved onto the field (with its extensions' options)"), +}).describe('The served form of a picklist-bound field: `options` resolved, `picklist` kept')); + +export type PicklistServedField = z.input; +/** Post-parse shape of {@link PicklistServedField} — defaults applied, transforms run (ADR-0122). */ +export type PicklistServedFieldParsed = z.infer; + +/** + * Type-safe factory for a picklist — the default export of a + * `*.picklist.ts` file. Validates at definition time. + */ +export function definePicklist(config: z.input): PicklistParsed { + return PicklistSchema.parse(config); +} diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 36e34ad7488..b66caeac33d 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -204,6 +204,7 @@ export type { } from './data/authoring-key-lint'; export { defineCube } from './data/analytics.zod'; export { defineMapping } from './data/mapping.zod'; +export { definePicklist } from './data/picklist.zod'; // `defineTheme` was removed by commit 35ad101bc with `ui/theme.zod.ts` (ADR-0049) — see // the block in `./ui/index.ts`; `app.branding` is the one colour surface. export { defineTranslationBundle } from './system/translation.zod'; diff --git a/packages/spec/src/kernel/functional-completeness.ts b/packages/spec/src/kernel/functional-completeness.ts index d894730fa59..baf14a85ae1 100644 --- a/packages/spec/src/kernel/functional-completeness.ts +++ b/packages/spec/src/kernel/functional-completeness.ts @@ -187,7 +187,11 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] { }); } - if (DEAD_WITHOUT_OPTIONS_ERROR.has(type) && !hasEntries(def.options)) { + // A `picklist` reference IS the field's option source (`data/picklist.zod.ts`): + // the served field carries the list's options resolved, so a picklist-bound + // choice is not the empty one this rule is about. + const hasOptionSource = hasEntries(def.options) || typeof def.picklist === 'string'; + if (DEAD_WITHOUT_OPTIONS_ERROR.has(type) && !hasOptionSource) { out.push({ rule: FIELD_CHOICE_WITHOUT_OPTIONS, severity: 'error', @@ -196,9 +200,9 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] { `A \`${type}\` field with no \`options\` is a choice with nothing to choose: the form ` + 'control is empty AND server-side value validation is disabled (`record-validator.ts` ' + 'skips the check when the allowed list is empty), so any value writes through the API.', - fix: "options: [{ label: '…', value: '…' }]", + fix: "options: [{ label: '…', value: '…' }] — or picklist: '' for a shared list", }); - } else if (DEAD_WITHOUT_OPTIONS_WARNING.has(type) && !hasEntries(def.options)) { + } else if (DEAD_WITHOUT_OPTIONS_WARNING.has(type) && !hasOptionSource) { out.push({ rule: FIELD_CHOICE_WITHOUT_OPTIONS, severity: 'warning', diff --git a/packages/spec/src/kernel/metadata-create-seeds.test.ts b/packages/spec/src/kernel/metadata-create-seeds.test.ts index 0fd95bfd014..625ca4d020c 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.test.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.test.ts @@ -78,6 +78,9 @@ describe('metadata create seeds validate against their spec schemas', () => { // Endpoints are authored as stack artifacts and shipped via // `publishPackage`. Same category as `capability`, not deferred work. 'api', + // `picklist` is package-owned (`allowRuntimeCreate: false`): there is no + // runtime create surface to seed. Same category as `capability`. + 'picklist', ]); const seeded = new Set(listMetadataCreateSeedTypes()); const missing = listMetadataTypeSchemaTypes().filter((t) => !seeded.has(t) && !KNOWN_UNSEEDED.has(t)); diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index c976ffa4378..47f68e76cb5 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -91,6 +91,7 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([ 'hook', // Data hooks (HookSchema) 'seed', // Seed/fixture data — runtime-draftable; publishing applies it (SeedSchema) 'mapping', // Import/export field mappings (MappingSchema) — consumed by POST /data/:object/import via mappingName (#2611); promoted to a kind per the ADR-0088 admission test once the consumer landed + 'picklist', // Shared option list that select fields reference by name (PicklistSchema) // UI Protocol 'view', // List/form views (ViewSchema) @@ -812,6 +813,16 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // `allowRuntimeCreate: true` so the import wizard can SAVE a hand-built // mapping as a named artifact; packaged mappings stay locked // (`allowOrgOverride: false`) like every artifact-backed item. + // `picklist`: a shared option list select fields reference by name + // (`Field.select({ picklist })`, `data/picklist.zod.ts`). PACKAGE-OWNED, so + // both runtime doors are closed: `allowRuntimeCreate: false` refuses a + // runtime create with 403 `not_creatable` and reads the prescription back + // from `filePatterns[0]`, and `allowOrgOverride: false` refuses a per-org + // overlay — an organization appending values is a later phase with its own + // admission (ADR-0005, additive only), not declared here. Another package + // adds values through `picklistExtensions`, on the artifact route. + // `loadOrder: 8` — before `object` (10), whose fields reference it. + { type: 'picklist', label: 'Picklist', description: 'Shared option list that select fields reference by name', filePatterns: ['**/*.picklist.ts', '**/*.picklist.yml', '**/*.picklist.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: false, supportsVersioning: false, executionPinned: false, loadOrder: 8, domain: 'data' }, { type: 'mapping', label: 'Import Mapping', description: 'Reusable import/export field mapping (rename + transforms), referenced by name at import', filePatterns: ['**/*.mapping.ts', '**/*.mapping.yml', '**/*.mapping.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: false, loadOrder: 96, domain: 'data' }, // UI Protocol diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index 932b705598d..4fdcc9bd422 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -634,7 +634,10 @@ describe('#4001 — registered-type closure is derived, not tallied', () => { // this schema either, and the conversion became an ordinary #4001 one. // `STILL_STRIP` shrinks to `view` alone, which IS the end state — its open // members are wire shapes with nowhere else to live (see that list's note). - expect(closed.length).toBe(25); - expect(types.length).toBe(26); + // + // 26 → 27 on 2026-09-30: `picklist` JOINED the registry, closed (a + // `strictObject` authoring surface), so the closed count moves with it. + expect(closed.length).toBe(26); + expect(types.length).toBe(27); }); }); diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 2fd10b37e58..962956ac0b8 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -34,6 +34,7 @@ import { HookSchema } from '../data/hook.zod'; import { DatasourceSchema } from '../data/datasource.zod'; import { SeedSchema } from '../data/seed.zod'; import { MappingSchema } from '../data/mapping.zod'; +import { PicklistSchema } from '../data/picklist.zod'; import { ViewMetadataSchema } from '../ui/view.zod'; import { PageSchema } from '../ui/page.zod'; @@ -96,6 +97,7 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // owning object. seed: SeedSchema, // fixture/init data; runtime-draftable, applied on publish mapping: MappingSchema as unknown as z.ZodType, // #2611: reusable import mapping; runtime-creatable so the wizard can save one + picklist: PicklistSchema, // shared option list select fields reference by name; package-owned (no runtime create) // UI Protocol // #3095 — a union over the three runtime `view` shapes (defineView container, diff --git a/packages/spec/src/meta-spelling/manifest-collection-spelling.ts b/packages/spec/src/meta-spelling/manifest-collection-spelling.ts index 1deaf26a62a..d86acfad8a2 100644 --- a/packages/spec/src/meta-spelling/manifest-collection-spelling.ts +++ b/packages/spec/src/meta-spelling/manifest-collection-spelling.ts @@ -90,6 +90,7 @@ export const PLURAL_TO_SINGULAR: Record = { ragPipelines: 'rag_pipeline', hooks: 'hook', mappings: 'mapping', + picklists: 'picklist', analyticsCubes: 'analytics_cube', connectors: 'connector', datasources: 'datasource', diff --git a/packages/spec/src/meta-spelling/meta-url-data.generated.ts b/packages/spec/src/meta-spelling/meta-url-data.generated.ts index e87a0ec3942..a40a2e45d03 100644 --- a/packages/spec/src/meta-spelling/meta-url-data.generated.ts +++ b/packages/spec/src/meta-spelling/meta-url-data.generated.ts @@ -38,6 +38,7 @@ export const META_URL_TO_SINGULAR: Readonly> = Object.fre "ragPipelines": "rag_pipeline", "hooks": "hook", "mappings": "mapping", + "picklists": "picklist", "analyticsCubes": "analytics_cube", "connectors": "connector", "datasources": "datasource", @@ -62,6 +63,7 @@ export const REGISTRY_DECLARED_META_TYPES: ReadonlyArray = Object.freeze "field", "hook", "seed", + "picklist", "mapping", "view", "page", diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index f916adad1fe..723a8356534 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -61,6 +61,7 @@ import { ToolSchema } from './ai/tool.zod'; // Data Protocol (additional) import { HookSchema } from './data/hook.zod'; import { MappingSchema } from './data/mapping.zod'; +import { PicklistSchema, PicklistExtensionSchema } from './data/picklist.zod'; import { CubeSchema } from './data/analytics.zod'; // Automation Protocol (additional) @@ -336,6 +337,27 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { */ objectExtensions: z.array(ObjectExtensionSchema).optional().describe('Extensions to objects owned by other packages'), + /** + * Shared option lists (`data/picklist.zod.ts`): one list of select options + * that fields on any object reference by name — `Field.select({ picklist: + * 'industry' })` — instead of each field copying an `options` array. + */ + picklists: z.array(PicklistSchema).optional().describe('Shared option lists that select fields reference by name'), + + /** + * Picklist Extensions: options to ADD to picklists owned by other packages — + * the `objectExtensions` idiom, additive only. + * + * @example + * ```ts + * picklistExtensions: [{ + * extend: 'industry', + * options: [{ label: 'Healthcare', value: 'healthcare' }], + * }] + * ``` + */ + picklistExtensions: z.array(PicklistExtensionSchema).optional().describe('Options added to picklists owned by other packages (additive only)'), + /** * ObjectUI: User Interface Layer * Apps, Menus, Pages, and Visualizations. @@ -1008,6 +1030,8 @@ export const COMPOSE_KEY_DISPOSITIONS = Object.freeze({ datasourceMapping: 'concat', translations: 'concat', objectExtensions: 'concat', + picklists: 'concat', + picklistExtensions: 'concat', apps: 'concat', views: 'concat', // [#5320] Machine-assembled channel (never authorable — the schema types it diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 1e7995c1148..2ed12d31f55 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1058,6 +1058,7 @@ const METADATA_DOCUMENT_TRANSLATORS: Record< // `@objectstack/rest` reads the derived set. dataset: translateDataset, page: translatePage, + picklist: translatePicklist, }; /** @@ -2798,9 +2799,16 @@ export function translateObject( const translatedHelp = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts); if (translatedHelp) next.help = translatedHelp; if (Array.isArray(def.options)) { + // A picklist-bound field is served with its list's options resolved + // onto it (`PicklistServedFieldSchema`), and INHERITS the list's option + // labels (`picklists..options.`); a field-level entry, when + // one exists, is the more specific and wins. + const picklist = typeof def.picklist === 'string' ? def.picklist : undefined; next.options = def.options.map((opt) => { if (!opt || typeof opt !== 'object' || opt.value === undefined) return opt; - const translated = lookupObjectFieldOption(bundle, objectName, name, opt.value, opts); + const translated = + lookupObjectFieldOption(bundle, objectName, name, opt.value, opts) ?? + (picklist !== undefined ? lookupPicklistOption(bundle, picklist, opt.value, opts) : undefined); return translated ? { ...opt, label: translated } : opt; }); } @@ -2845,6 +2853,82 @@ export function translateObject( }; } +// ──────────────────────────────────────────────────────────────────────────── +// Picklist resolvers (label / options) — `picklists.` +// ──────────────────────────────────────────────────────────────────────────── + +/** Minimal picklist metadata shape consumed by `translatePicklist`. */ +export interface PicklistLike { + name: string; + label?: string; + options?: Array<{ label?: string; value: string | number | boolean; [key: string]: unknown }>; + [key: string]: unknown; +} + +function lookupPicklistLabel( + bundle: TranslationBundle | undefined, + picklistName: string, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.picklists?.[picklistName]?.label; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +function lookupPicklistOption( + bundle: TranslationBundle | undefined, + picklistName: string, + optionValue: string | number | boolean, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + const key = String(optionValue); + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.picklists?.[picklistName]?.options?.[key]; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * Apply the active locale to a picklist metadata document — its `label` + * against `picklists..label`, and each option's `label` against + * `picklists..options.`. The same option labels are what every + * referencing field inherits in {@link translateObject}, so the list is + * translated once. The input document is not mutated; a key the bundle does + * not carry leaves the authored value in place. + */ +export function translatePicklist( + doc: T, + bundle: TranslationBundle | undefined, + opts?: TranslateDocumentOptions, +): T { + if (!doc || typeof doc !== 'object' || typeof doc.name !== 'string' || !bundle) return doc; + const picklistName = doc.name; + const label = lookupPicklistLabel(bundle, picklistName, opts); + let options = doc.options; + if (Array.isArray(doc.options)) { + let changed = false; + const next = doc.options.map((opt) => { + if (!opt || typeof opt !== 'object' || opt.value === undefined) return opt; + const translated = lookupPicklistOption(bundle, picklistName, opt.value, opts); + if (!translated) return opt; + changed = true; + return { ...opt, label: translated }; + }); + if (changed) options = next; + } + if (label === undefined && options === doc.options) return doc; + return { + ...doc, + ...(label !== undefined ? { label } : {}), + ...(options !== doc.options ? { options } : {}), + }; +} + // ──────────────────────────────────────────────────────────────────────────── // Settings (SettingsManifest) resolvers // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 1aa1b108e28..0279a865f87 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -391,6 +391,13 @@ const LEDGER: ReadonlyArray = [ key: 'useGrouping', why: 'declared, not enforced yet — liveness verdict `planned` (the renderer read side that maps it onto `Intl.NumberFormat` is not landed). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate', }, + { + kind: 'omit', + type: 'field', + path: ROOT_PATH, + key: 'picklist', + why: 'declared, not enforced yet — liveness verdict `planned` (the server-side resolution that serves a picklist-bound field its options is not landed). No offer until it is enforced; the field designer offering a picklist is a later Studio phase', + }, { kind: 'omit', type: 'page', diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index fd69ae8f572..9763211dd54 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -585,9 +585,9 @@ const PER_APP_SETTINGS_PLATFORM_ONLY = + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + 'literal, so correct it there rather than filling the gap from an application. For an ' - + 'application\'s own copy use the ten groups this bundle does declare, in the order it ' - + "declares them — 'objects', 'apps', 'messages', 'globalActions', 'dashboards', 'datasets', " - + "'pages', 'flows', 'metadataForms', 'settingsCommon'. Note the last one: 'settingsCommon' IS " + + 'application\'s own copy use the eleven groups this bundle does declare, in the order it ' + + "declares them — 'objects', 'picklists', 'apps', 'messages', 'globalActions', 'dashboards', " + + "'datasets', 'pages', 'flows', 'metadataForms', 'settingsCommon'. Note the last one: 'settingsCommon' IS " + 'on this face, so the Settings UI shell strings an application may translate (the source ' + 'badges, under `settingsCommon.sourceLabels`) are NOT what is being refused here — only the ' + "per-namespace manifest copy under 'settings' is. " @@ -628,7 +628,7 @@ const ITEM_SETTINGS_PLATFORM_ONLY = + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + 'literal, so correct it there rather than overriding it from an application. For an ' - + 'application\'s own copy use the groups this item does declare — the same ten a per-app ' + + 'application\'s own copy use the groups this item does declare — the same eleven a per-app ' + "bundle declares, 'settingsCommon' among them: the Settings UI shell strings an application " + 'may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT what is being ' + "refused here — only the per-namespace manifest copy under 'settings' is. " @@ -667,12 +667,12 @@ const ITEM_TRANSLATION_KEY_GUIDANCE: Record = { */ /** * The translation groups an APPLICATION may author, as a shape rather than a - * schema. Ten groups — the eleventh, `settings`, is platform-only and lives in + * schema. Eleven groups — the twelfth, `settings`, is platform-only and lives in * {@link platformSettingsShape}. * * Three schemas need exactly these keys: {@link TranslationDataSchema} (one - * entry of a per-app file-authored bundle — these ten and no more), - * {@link PlatformTranslationDataSchema} (these ten plus `settings`) and + * entry of a per-app file-authored bundle — these eleven and no more), + * {@link PlatformTranslationDataSchema} (these eleven plus `settings`) and * {@link TranslationItemSchema} (the registered `translation` metadata type — * the per-app face plus `locale`, its identity keys and the ADR-0010 * envelope; it carried the platform face's `settings` too until #19620). The item used to @@ -740,6 +740,29 @@ const appTranslationDataShape = () => ({ /** Object translations */ objects: z.record(z.string(), ObjectTranslationDataSchema).optional().describe('Object translations keyed by object name'), + /** + * Picklist translations keyed by picklist name (`Picklist.name`, + * `data/picklist.zod.ts`). + * + * picklists..label → the picklist's own `label` + * picklists..options. → the label of the option whose `value` matches + * + * Every field that references the picklist (`Field.select({ picklist })`) + * inherits these option labels — the list is translated once, not per + * field. A field with inline `options` keeps its own + * `objects..fields..options`. + */ + picklists: z.record(z.string(), strictObject({ + surface: 'this picklist translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', values: 'options', choices: 'options' }, + }, { + label: z.string().optional().describe('Translated picklist label'), + options: z.record(z.string(), z.string()).describe( + 'Option value to translated label map — inherited by every field that references the picklist', + ), + })).optional().describe('Picklist translations keyed by picklist name'), + /** App/Menu translations */ apps: z.record(z.string(), strictObject({ surface: 'this app translation', @@ -1434,10 +1457,10 @@ const platformSettingsShape = () => ({ * One locale of a PER-APP translation bundle — `stack.translations`, and * everything {@link defineTranslationBundle} builds. * - * Ten groups: every group the platform bundle declares EXCEPT `settings`, + * Eleven groups: every group the platform bundle declares EXCEPT `settings`, * which is platform-only and is refused here by name with * {@link PER_APP_SETTINGS_PLATFORM_ONLY} as the remedy. See - * {@link PlatformTranslationDataSchema} for the eleven-group face and for why + * {@link PlatformTranslationDataSchema} for the twelve-group face and for why * the two are separate namespaces. */ export const TranslationDataSchema = lazySchema(() => strictObject({ @@ -1448,7 +1471,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ // `settings`, and an alias prescribing a key the shape rejects is a // suggestion the author cannot take (the `alias-integrity` audit judges // exactly that). Both spellings are answered by `guidance` above instead. - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', flow: 'flows', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', picklist: 'picklists', flow: 'flows', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, // `locale` lives on the ITEM, not on a bundle entry (the bundle keys ARE the // locales). Naming it keeps the suggestion useful for an author who moved a // `translation` item into a bundle and left the field behind. @@ -1458,7 +1481,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ export type TranslationData = z.input; /** - * One locale of a PLATFORM translation bundle — the eleven groups, `settings` + * One locale of a PLATFORM translation bundle — the twelve groups, `settings` * included. * * The platform's own bundles are code, not authored metadata @@ -1493,7 +1516,7 @@ export const PlatformTranslationDataSchema = lazySchema(() => strictObject({ surface: 'this locale of the platform translation bundle', history: TRANSLATION_HISTORY, guidance: TRANSLATION_KEY_GUIDANCE, - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', picklist: 'picklists', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, extraKeys: ['locale'], }, { ...appTranslationDataShape(), @@ -1609,7 +1632,7 @@ export type TranslationConfig = z.input; * to whoever — or whatever — authored it. * * `settings` is NOT on this door (#19620, ruling batch #210 item 2 letter B): - * the item takes the PER-APP face, the same ten groups as + * the item takes the PER-APP face, the same eleven groups as * {@link TranslationDataSchema}, and refuses `settings` (and the singular * `setting`) by name with {@link ITEM_SETTINGS_PLATFORM_ONLY} as the remedy. * The file door and the item door are two authoring surfaces for one app @@ -1648,7 +1671,7 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ // this door no longer declares `settings`, and an alias prescribing a key // the shape rejects is a suggestion the author cannot take. Both spellings // are answered by `guidance` above instead. - aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', flow: 'flows', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, + aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', picklist: 'picklists', flow: 'flows', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, }, { ...appTranslationDataShape(), locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index 63213c9d0d8..f12c49e59a7 100644 --- a/packages/spec/test-typecheck-debt.json +++ b/packages/spec/test-typecheck-debt.json @@ -118,9 +118,6 @@ "TS6133: 'query' is declared but its value is never read.": 1, "TS6133: 'schema' is declared but its value is never read.": 1 }, - "src/data/field.test.ts": { - "TS2322: Type 'string' is not assignable to type '…'.": 2 - }, "src/data/object-strictness-batch20.test.ts": { "TS2352: Conversion of type '…' to type '…' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.": 1 }, diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs index eab9ab50ec8..75c50a96102 100644 --- a/scripts/check-stack-collection-maps.mjs +++ b/scripts/check-stack-collection-maps.mjs @@ -486,19 +486,19 @@ const SITES = [ waivers: [ { direction: 'missing', - keys: ['views', 'objectExtensions', 'data', 'translations'], + keys: ['views', 'objectExtensions', 'picklistExtensions', 'data', 'translations'], reason: - 'the four SHAPE exclusions the declaration already documents, pinned here so the prose is checked: ' - + 'ViewSchema has no `name` (its identity is the target object), ObjectExtensionSchema keys by ' + 'the five SHAPE exclusions, pinned here so the prose is checked: ViewSchema has no `name` (its ' + + 'identity is the target object), ObjectExtensionSchema and PicklistExtensionSchema key by ' + '`extend`, SeedSchema keys by `object`, and TranslationBundleSchema is itself a record.', }, { direction: 'missing', - keys: ['datasourceMapping', 'capabilities', 'docs', 'books', 'tools', 'skills'], + keys: ['datasourceMapping', 'capabilities', 'docs', 'books', 'tools', 'skills', 'picklists'], reason: 'no map form has ever been offered for these. `datasourceMapping` is a rule LIST whose precedence is ' - + 'positional, so a map would lose the ordering that decides which rule wins; the other five are ' - + 'array-only authoring surfaces. Offering a map form for any of them WIDENS what parses — an ' + + 'positional, so a map would lose the ordering that decides which rule wins; the other six are ' + + 'array-only authoring surfaces (`picklists` since it was declared). Offering a map form for any of them WIDENS what parses — an ' + 'acceptance change, which belongs to its own reviewed diff rather than to this gate.', }, ], @@ -525,13 +525,13 @@ const SITES = [ }, { direction: 'missing', - keys: ['datasourceMapping', 'translations', 'objectExtensions', 'data'], + keys: ['datasourceMapping', 'translations', 'objectExtensions', 'picklistExtensions', 'data'], reason: - 'there is no singular metadata-type name to map to. `objectExtensions` merges into its target ' - + 'object rather than registering as a type, `datasourceMapping` is stack-level routing ' - + 'configuration, `translations` is a bundle record consumed by the i18n resolvers, and `data` ' - + 'seeds are applied by SeedLoaderService. `pluralToSingular` returns its input unchanged for all ' - + 'four, which is the correct answer rather than a gap.', + 'there is no singular metadata-type name to map to. `objectExtensions` and `picklistExtensions` ' + + 'merge into their target rather than registering as a type, `datasourceMapping` is stack-level ' + + 'routing configuration, `translations` is a bundle record consumed by the i18n resolvers, and ' + + '`data` seeds are applied by SeedLoaderService. `pluralToSingular` returns its input unchanged ' + + 'for all five, which is the correct answer rather than a gap.', }, ], }, @@ -559,13 +559,13 @@ const SITES = [ keys: [ 'datasources', 'datasourceMapping', 'objectExtensions', 'apps', 'jobs', 'emailTemplates', 'docs', 'books', 'positions', 'capabilities', 'sharingRules', 'webhooks', 'tools', 'skills', - 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'data', + 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'data', 'picklists', 'picklistExtensions', ], reason: 'DRIFT in the opposite direction, under the same acceptance constraint: ADDING a member widens ' + 'what an artifact may declare and presumes a packaging layout (one subdirectory per category) ' + 'that does not exist for these. The omissions are inert today because this enum is not the ' - + 'artifact ingest path — `ARTIFACT_FIELD_TO_TYPE` below is — but 19 of 32 collections absent is ' + + 'artifact ingest path — `ARTIFACT_FIELD_TO_TYPE` below is — but 21 of 33 collections absent is ' + 'the measurement that says so out loud (#6242 row 5).', }, ], @@ -619,6 +619,15 @@ const SITES = [ 'ADR-0090 D3 positions reach the registry through the security bootstrap, which reads them off the ' + 'stack directly; the loop\'s sibling `permissions` entry is what makes the absence look like a gap.', }, + { + direction: 'missing', + keys: ['picklists', 'picklistExtensions'], + reason: + 'PENDING the picklist runtime layer (#19519): the spec declares `picklists` and ' + + '`picklistExtensions` ahead of their reader, by ruling (the spec layer lands first). Registering ' + + 'them — and merging the extensions into their target list — is that layer\'s work, so this row ' + + 'goes STALE, and fails, the day it lands. The liveness ledger grades the same keys `planned`.', + }, ], }, { @@ -676,6 +685,15 @@ const SITES = [ keys: ['datasourceMapping'], reason: 'stack-level routing configuration, never a registry item.', }, + { + direction: 'missing', + keys: ['picklists', 'picklistExtensions'], + reason: + 'PENDING the picklist runtime layer (#19519): the spec declares `picklists` and ' + + '`picklistExtensions` ahead of their reader, by ruling (the spec layer lands first). Registering ' + + 'them — and merging the extensions into their target list — is that layer\'s work, so this row ' + + 'goes STALE, and fails, the day it lands. The liveness ledger grades the same keys `planned`.', + }, ], }, { @@ -700,6 +718,7 @@ const SITES = [ keys: [ 'objectExtensions', 'datasourceMapping', 'datasources', 'jobs', 'apis', 'webhooks', 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'capabilities', 'datasets', + 'picklists', 'picklistExtensions', ], reason: 'DELIBERATE, and load-bearing. This is a HEURISTIC, not a registration list: it answers "did the ' @@ -737,7 +756,7 @@ const SITES = [ 'datasources', 'datasourceMapping', 'translations', 'objects', 'objectExtensions', 'apps', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'actions', 'flows', 'jobs', 'emailTemplates', 'docs', 'books', 'apis', 'webhooks', 'agents', 'tools', 'skills', - 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'data', + 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'data', 'picklists', 'picklistExtensions', ], reason: 'DELIBERATE — a four-collection SUBSET, not an enumeration of the collection set. This block ' diff --git a/scripts/fixtures/i18n-walk-parity/every-group.stack.json b/scripts/fixtures/i18n-walk-parity/every-group.stack.json index eb5fa93c9e3..b96339c4d67 100644 --- a/scripts/fixtures/i18n-walk-parity/every-group.stack.json +++ b/scripts/fixtures/i18n-walk-parity/every-group.stack.json @@ -124,6 +124,16 @@ } ], + "picklists": [ + { + "name": "parity_tier", + "label": "Parity Tier", + "options": [ + { "label": "Gold", "value": "gold" } + ] + } + ], + "pages": [ { "name": "parity_page", diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 3a85ad34d1a..ad7e7b7c8b3 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -41,8 +41,9 @@ It calls `defineStack()` to declare all metadata. ### Full Configuration Reference `defineStack()` accepts an `ObjectStackDefinitionInput` whose top-level keys -are `manifest`, `packages`, `objects`, `objectExtensions`, `views`, `apps`, -`pages`, `dashboards`, `reports`, `datasets`, `actions`, `flows`, `jobs`, +are `manifest`, `packages`, `objects`, `objectExtensions`, `picklists`, +`picklistExtensions`, `views`, `apps`, `pages`, `dashboards`, `reports`, +`datasets`, `actions`, `flows`, `jobs`, `emailTemplates`, `docs`, `books`, `positions`, `permissions`, `capabilities`, `sharingRules`, `apis`, `webhooks`, `api`, `server`, `agents`, `tools`, `skills`, `hooks`, `functions`, `mappings`, @@ -449,9 +450,8 @@ definition: never write it by hand, and never hand-write a raw ## CLI Commands -Daily commands are covered in **Part 3 — Operations** below -([jump there](./references/operations.md#part-3--operations-cli-testing-deployment)). High-level cheat -sheet for the bootstrap loop: +Daily commands are in [Part 3 — Operations](./references/operations.md#part-3--operations-cli-testing-deployment). +Cheat sheet for the bootstrap loop: ```bash npx create-objectstack my-app