Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nine-camels-behave.md
Original file line numberDiff line numberDiff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/references/data/field.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand Down
24 changes: 21 additions & 3 deletions packages/spec/src/data/field.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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).
Expand Down
Loading