From e48b04963ec75e057ce158b5b77f9bb5bdd03838 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 04:14:12 +0000 Subject: [PATCH] docs(spec): FieldSchema pins the ruled multi-value empty representation (#9447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An emptied multi-value lookup reads back as [], never null — binding for every writer (cascade repair, form clears, API writes) — and required on a multi-value lookup means non-empty array (maintainer ruling 2026-08-18, recorded on #9447). Doc blocks on FieldSchema.required and FieldSchema.multiple; content/docs/references regenerated via gen:docs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- .changeset/nine-camels-behave.md | 5 +++++ content/docs/references/data/field.mdx | 4 ++-- packages/spec/src/data/field.zod.ts | 24 +++++++++++++++++++++--- 3 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 .changeset/nine-camels-behave.md diff --git a/.changeset/nine-camels-behave.md b/.changeset/nine-camels-behave.md new file mode 100644 index 0000000000..8984fb2ee9 --- /dev/null +++ b/.changeset/nine-camels-behave.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": patch +--- + +`FieldSchema` docs now pin the ruled multi-value lookup empty representation (#9447, maintainer ruling 2026-08-18): an emptied multi-value lookup reads back as `[]`, never `null` — binding for every writer (cascade repair, form clears, API writes) — and `required` on a multi-value lookup means non-empty array, so an emptied required set fails validation loudly. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 8bb247abe5..439ac7036a 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -59,10 +59,10 @@ const result = CurrencyConfigSchema.parse(data); | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Format string (e.g. email, phone) | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (#9447, maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `number` | optional | Max character length | diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 37434cc6a4..bb77efa5a6 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -753,8 +753,16 @@ export const FieldSchema = lazySchema(() => strictObject({ // a custom column name was silently ignored. External/federated objects map // physical columns via `external.columnMap` (ADR-0062 D7 / ADR-0015). - /** Write contract (ADR-0113 — NOT a column constraint; see `storage.notNull`) */ - required: z.boolean().default(false).describe('Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field.'), + /** + * Write contract (ADR-0113 — NOT a column constraint; see `storage.notNull`). + * + * On a multi-value lookup (`multiple: true`), `required` means NON-EMPTY + * array: an emptied required set fails validation loudly — `[]` does not + * satisfy `required` (#9447, maintainer ruling 2026-08-18). The empty set is + * always representable (it reads back as `[]`, never `null` — see + * `multiple`), so the required check judges emptiness, not absence. + */ + required: z.boolean().default(false).describe('Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (#9447, maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field.'), /** * Physical storage constraints (ADR-0113). Deliberately separate from the @@ -772,7 +780,17 @@ export const FieldSchema = lazySchema(() => strictObject({ }).strict().optional().describe('Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested.'), searchable: z.boolean().default(false).describe('Is searchable'), - multiple: z.boolean().default(false).describe('Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image.'), + /** + * Multi-value empty representation (#9447, maintainer ruling 2026-08-18): + * an emptied multi-value lookup reads back as `[]`, never `null`. This + * binds the field's empty representation for EVERY writer — cascade repair + * (`set_null` member removal), form clears, API writes — not as a + * cascade-only convention: an array field always reads as an array, so + * readers (generated code, formula/filter predicates) never need a null + * branch. Same ruling: `required` on a multi-value lookup means non-empty + * array (see `required` above). + */ + multiple: z.boolean().default(false).describe('Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18).'), // `true` = unique WITHIN the tenant on a tenant-scoped object (composite // `(tenantField, field)` index); `'global'` = platform-wide single-column // unique. See {@link UniqueScopeSchema} for the scope vocabulary (ADR-0120).