Skip to content

Commit 29c70ca

Browse files
feat(spec): a field marked searchable: true is searched when its object declares no searchableFields (#29385)
director#1 · zhuangjianguo · session_01QsR2jV5sBSzCYNEDgpYLJw Ruled: 6580271401 · letter A · 2026-11-02T14:58Z <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Uq4LmxqZBkQTT4zD3bmPrk)_ Fixes #29008 Clause-②: yes (widening) Metadata-protocol: semantic Skills-docs: follow-up owed — skills/objectstack-data/SKILL.md, skills/objectstack-query/SKILL.md, skills/objectstack-ui/rules/list-views.md (the content/docs pages are updated here) ## 维护者速读 - **改了什么**:对象没有声明 `searchableFields` 时,标了 `searchable: true` 的字段现在会被 `$search` 扫描,`$searchFields` 点名它也会被接受,不再返回 400;`secret` / `password` / `vector` 即使标了也永远不搜。声明了 `searchableFields` 的对象完全不变。 - **为什么改**:这个开关以前能写、有默认值、schema 里也返回 `searchable: true`,但没有任何代码读它:作者标了却搜不到,点名还被 400 拒绝。按裁决记录 6573569376 的 A 案(维护者已同意)补上读取方。 - **风险与回滚**:这是放宽。已经标了 `searchable: true` 的非文本字段会开始被搜;平台对象实测有 6 个对象的 7 个用户 id 类 lookup 字段因此被搜到(匹配的是存储的 id,不是关联记录的标题)。回滚即 revert 本 PR;单个对象也可以删掉标记,或声明 `searchableFields` 把集合钉死。 - **先看**:① `packages/spec/src/data/search-fields.ts` 的 `admittedBySearchableFlag` 与 `autoDefaultFields` 第一行(标记先于 hidden / 系统字段名排除判断);② `packages/metadata-protocol/src/protocol-query-validation.ts` 的 400 文案;③ `packages/lint/src/validate-searchable-fields.ts` 的 lint 文案。 - **两分钟验证**:在已构建的工作区跑 `pnpm --filter @objectstack/rest exec vitest run src/data-search-field-searchable-flag-door.test.ts`,预期 4/4 通过:卡片的三个请求里 QQCODE、QQHTML 都能找到那一行,`$searchFields=snippet` 返回 200。 - **需要你做的**:无,席位落地 ## What changes - **The resolver** (`packages/spec/src/data/search-fields.ts`). The auto branch of `resolveSearchFieldResolution` now admits a field marked `searchable: true` unless its type is one of the new `SEARCH_FLAG_EXCLUDED_TYPES` (`secret`, `password`, `vector`) or it is a virtual `formula` with no stored column. The flag is asked before the auto-default's own exclusions, so a flagged `hidden` field and a flagged field named like a system column join too, which is the ruling's "every field marked `searchable: true`, of any type". The declared branch is unchanged and reads no flag. `SearchFieldMeta` gains the optional `searchable`. - **Every reader moves with it.** The #4254 ingress gate (`assertSearchFieldsAreSearchable`), the engine's `$search` expansion, the lint rule and the ui readers (the fields search offer and `ListView`) all call this one resolver. Measured: the protocol and the ui pass the registry's field map unprojected, so no `domain:server` half was needed. The one projection that dropped `searchable` was the lint rule's `indexObjectSearchTargets`; it now carries the flag. - **The describe** on `FieldSchema.searchable` (`packages/spec/src/data/field.zod.ts`) says the ruling's sentence instead of "Is searchable", and the reference pages are regenerated (`content/docs/references/data/field.mdx`, `data/object.mdx`, `system/migration.mdx`, plus `api-surface` / `export-origins` for the new export). - **Messages.** For a field outside the auto-default, the ingress `400 INVALID_FIELD` and the `searchable-field-unsearchable` lint verdict now name the missing `searchable: true` and offer it as the fix, where they used to say the type cannot be scanned. For the three hard-excluded types they keep the old remedy and say the flag does not admit them. The `os explain` text for the rule says the same. - **Docs.** `content/docs` pages that described the auto-default as type-only now name the flag (data-api, queries, query-syntax, schema-design, and the `searchable` rows in field-types and fields). - **Liveness.** The `searchable` row in `packages/spec/liveness/field.json` now points at the real reader. Its old evidence was a dashboard widget's own `searchable` key, not this field. - **Changeset.** `@objectstack/spec`, `@objectstack/metadata-protocol` and `@objectstack/lint` are minor (widening, new export), with the claim's two lines at line start. ## Tests - `packages/spec/src/data/search-fields.test.ts`, new describe "field.searchable: true joins the auto-default set — never a declared one". It covers the card's reproduction (`code` and `html` fields marked searchable resolve into the set) and an override naming a flagged field narrowing to it. It also covers these CONTROLS: an unflagged field resolves as before, each of `secret` / `password` / `vector` stays out even when flagged, a virtual `formula` stays out, a declared `searchableFields` ignores the flag, and absent and `false` give one answer. It also pins that every other `FieldType` is admitted when flagged, that a flagged hidden field and a flagged system name join (with unflagged controls), that a field with no readable type is not admitted, and that the display field still only orders. A second describe pins `SEARCH_FLAG_EXCLUDED_TYPES`: exactly the three types, covering every masked-on-read type, and a subset of the auto-default's own exclusions. - `packages/lint/src/validate-searchable-fields.test.ts`, new describe for the flag. A view narrowing to flagged `code` / `html` fields lints clean. An unflagged one is refused with the exact new hint. A flagged `vector` is refused with the hard-exclusion verdict and the old hint. A declared set ignores the flag. `indexObjectSearchTargets` carries the flag. - `packages/rest/src/data-search-field-searchable-flag-door.test.ts` (new) runs the card's three requests end to end over the Data API door on a real `SqlDriver` (SQLite). Its controls: an unflagged `code` field is not scanned and naming it is `400` before any driver read, a flagged `secret` is `400` before any read, and an object that declares `searchableFields` keeps the declaration as the whole set. - **Forced test edit (named):** `packages/lint/src/validate-searchable-fields.test.ts`, the skill-parity pin for a `lookup` outside the auto-default. It expected the verdict to say "of type 'lookup', which 'search' cannot scan". It now expects "of type 'lookup' and not marked `searchable: true`". The old sentence is false after this change, because a marked lookup is now scanned. ## Acceptance notes 1. **What already matches more, measured on the platform's own objects.** Seven lookup fields on six objects are already marked `searchable: true` with no `searchableFields`. These are `sys_session.user_id`, `sys_activity.actor_id` and `environment_id`, `sys_audit_log.user_id`, `sys_comment.author_id`, `sys_notification_preference.user_id` and `sys_presence.user_id`. They now join `$search` on their stored ids, not on the user's name. This is a static census of the source, not a runtime reading. 2. **Flagged types with no text column.** The ruling admits a flagged field "of any type", so a flagged `number`, `boolean`, `date`, `datetime`, `time` or `json` field now joins the set, and the engine emits `$icontains` for it. That is NOT MEASURED on Postgres in this PR. The same shape already exists today for a declared `searchableFields` naming such a column, so it is not new to this PR. It is listed as out of scope for `domain:server`, with a one-line narrowing offered in the report. 3. **ADR-0061 D2** still says field-level `searchable` "stays pruned". That sentence predates ruling 6573569376, and the code docblock says so. An amendment note is owed on a governed path (Tier H). It is not in this PR, so this PR stays ungoverned. The seat has a draft. 4. Left unchanged on purpose: the engine's empty-search-set refusal still names `searchableFields` as its only remedy, and the field form's help text "Include in global search results" is now accurate as written. 5. **The ui readers agree with the server without a ui edit.** They pass the full field meta to the same resolver, and their tests are green (below). ## Verification Head `85ec00115a6b5179a99240050a1d9509638e2656`, merge base `2f7977cd2`, a clean merge with `origin/main` at `57cde8cb`. <details> <summary>Consumer suites (each through os-verify-lock, VERDICT command-exit 0)</summary> ```text turbo build ./packages/** Tasks 78 successful, 78 total @objectstack/spec test Test Files 791 passed (791) · Tests 23409 passed | 1 todo @objectstack/spec typecheck exit 0 (check:test-typecheck clean) @objectstack/lint test Test Files 189 passed (189) · Tests 7835 passed @objectstack/lint typecheck exit 0 @objectstack/metadata-protocol test Test Files 301 passed | 4 skipped (305) · Tests 22127 passed | 26 skipped @objectstack/metadata-protocol typecheck exit 0 @objectstack/objectql search files (search-filter, search-companion, engine-empty-search-fields-refusal, query-expression-conformance, filter-comparand-shape) Test Files 5 passed (5) · Tests 331 passed @objectstack/rest search files (data-search*, list-search, meta-object-search, search-clone, discovery-search, list-view-grouping-query-door, rest-data*) Test Files 15 passed (15) · Tests 425 passed | 22 skipped @objectstack/rest typecheck exit 0 ui: fields searchOffer, RecordPickerDialog.searchlessTarget-12966, ListView.searchlessObject-12966, ListView.searchWithheldTreeChart-10326 Test Files 4 passed (4) · Tests 39 passed ``` </details> <details> <summary>Ablation: the flag line removed, rebuilt, proven in dist, then restored</summary> The line `if (meta && admittedBySearchableFlag(meta)) return true;` was replaced with a marker that never matches (`ablation-replace.mjs --hold`). After a rebuild, `ablation-dist-preflight.mjs` found the marker in 2 built files of `packages/spec/dist`. ```text spec search-fields.test.ts Tests 5 failed | 45 passed (50) (the reproduction, the override, every-other-type, hidden/system-name, display-field order) lint validate-searchable-fields.test.ts Tests 1 failed | 72 passed (73) (flagged code/html lints clean) rest data-search-field-searchable-flag-door Tests 1 failed | 3 passed (4) (the card's three requests) ``` Every control stayed green with the flag unread. Restore: blob `d45c69a6` equals HEAD, `git diff HEAD` empty. After rebuilding, `--absent` preflight found the marker absent from all 588 built files and the working tree clean. </details> <details> <summary>Derived gates (dispatch-gates --commands, then --ran)</summary> At head `85ec0011`, 21 changed paths against the merge base. Each command ran with stdin closed, and its exit code was recorded before any pipe. ```text derived 106 · run 106 · exit 0 on 106 · NOT-MEASURED 0 · UNRUN 0 ✓ dispatch-gates --ran: 106 derived famil(ies) accounted for — 106 run, 0 NOT-MEASURED ``` One command first answered exit 3, a prerequisite and not a verdict: `node scripts/check-plugin-teardown-shape.mjs --self-test` could not read its positive control pinned at commit `621a4876` in this shallow clone. After fetching that one commit it answered exit 0 (48 cases pass). Offline `node scripts/check-changeset-no-major.mjs --base origin/main` was run against this body as the event and answered exit 0. It found no major, and on the level axis it read `Clause-②: yes (widening)` and `Metadata-protocol: semantic` with no moved package graded patch. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmSR2TvJTmoYyVtcQv9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ca9dd7 commit 29c70ca

21 files changed

Lines changed: 559 additions & 33 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/lint': minor
5+
---
6+
7+
feat(spec): a field marked `searchable: true` is searched when its object declares no `searchableFields`
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: semantic
11+
12+
`FieldSchema.searchable` was declared, defaulted and served, and nothing read it: `$search` skipped a field marked `searchable: true`, and a `$searchFields` override naming it was refused `400 INVALID_FIELD` "is not searchable" while the object's served schema said `searchable: true`. It is now honoured (ruling record 6573569376, letter A).
13+
14+
- **Before.** With no `searchableFields` declared, the search set was the type-based auto-default only: the text-like and `select` columns. A `code`, `html`, `lookup` or `json` field marked `searchable: true` was not scanned, and naming it in `$searchFields` answered 400.
15+
- **Now.** With no `searchableFields` declared, the set is the type-based auto-default plus every field marked `searchable: true`, of any type except `secret`, `password` and `vector`, which stay excluded (a virtual `formula` field has no stored column and stays out too). The `$searchFields` ingress allow-list is the same set, so naming a marked field narrows the search to it instead of answering 400. The flag is read before the auto-default's own exclusions, so a marked `hidden` field or a marked field named like a system column joins too.
16+
- **Unchanged.** A declared `searchableFields` is still the whole search set, and no field's own flag is read there. A field without the flag (or with `searchable: false`, the default) resolves exactly as before. A credential field is still never scanned.
17+
- **What may now match more.** A field you already marked `searchable: true` on an object without `searchableFields` is now part of `$search`. For a `lookup` that means its stored record id, not the related record's title. Remove the flag, or declare `searchableFields` to pin the set.
18+
- **New export.** `SEARCH_FLAG_EXCLUDED_TYPES` (`@objectstack/spec/data`) names the three hard exclusions. `SearchFieldMeta` gains the optional `searchable`.
19+
- **Messages.** The `$searchFields` refusal for a field outside the auto-default, and the `searchable-field-unsearchable` lint verdict, now name the missing `searchable: true` and offer it as the fix, instead of calling the field's type unscannable.

‎content/docs/api/data-api.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Query records with filtering, sorting, selection, and pagination.
2323
| `top` | query | Max records to return. No default — omitting it returns all matching records. |
2424
| `skip` | query | Offset |
2525
| `expand` | query | Comma-separated list of relations to eager-load. Must name a reference field (`lookup` / `master_detail` / `user` / `tree`) — otherwise `400 INVALID_FIELD`. |
26-
| `search` | query | Full-text search term, scanned case-insensitively across the object's searchable fields (its declared `searchableFields`, or a text-like auto-default when none are declared). When that set is empty — no `searchableFields` entry names a stored field, and the auto-default finds none — a non-blank term is `400 INVALID_FIELD`, never every row. |
26+
| `search` | query | Full-text search term, scanned case-insensitively across the object's searchable fields (its declared `searchableFields`, or, when none are declared, a text-like auto-default plus every field marked `searchable: true` except `secret` / `password` / `vector`). When that set is empty — no `searchableFields` entry names a stored field, and the auto-default finds none — a non-blank term is `400 INVALID_FIELD`, never every row. |
2727
| `searchFields` | query | Comma-separated subset of the searchable fields for `search` to scan — narrows the scan, never widens it. A name outside the searchable set is `400 INVALID_FIELD`. |
2828

2929
> **Note:** OData-style `$`-prefixed parameters (`$filter`, `$select`, `$orderby`, `$top`, `$skip`, `$expand`, `$count`, `$search`) are also accepted directly on this same endpoint as aliases — they're normalized internally to the parameter names above. There is no separate standalone OData endpoint.

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1023,7 +1023,7 @@ These properties are available on **all** field types:
10231023
| `required` | `boolean` | `false` | Whether the field is required |
10241024
| `unique` | `boolean \| 'global' \| 'organization'` | `false` (`'organization'` on `autonumber`) | Unique constraint **and its scope** (ADR-0120). `'organization'` = one holder per organization; `true` = that same per-organization scope (positional synonym — prefer the explicit spelling in new code); `'global'` = one holder across the whole installation; `false` = no constraint. Omitted ⇒ `false`, except on an `autonumber` field, where omitted ⇒ `'organization'` — an auto-number is a business identifier, so the platform makes it unique per organization by default; write `unique: false` explicitly to opt out. `'tenant'`/`'org'` are rejected — the word is `'organization'`. Full rule in the `unique` doc block in [`FieldSchema`](/docs/references/data/field) |
10251025
| `multiple` | `boolean` | `false` | Allow array of values (multi-record lookup, multi-select, multi-file). An emptied multi-value lookup reads back as `[]`, never `null` — the `multiple` doc block in [`FieldSchema`](/docs/references/data/field) |
1026-
| `searchable` | `boolean` | `false` | Include in search index |
1026+
| `searchable` | `boolean` | `false` | Search this field when the object declares no `searchableFields` — any type but `secret` / `password` / `vector`; a declared `searchableFields` is the whole set |
10271027
| `sortable` | `boolean` | `true` | Allow sorting by this field |
10281028
| `hidden` | `boolean` | `false` | Hide from default views |
10291029
| `readonly` | `boolean` | `false` | Server contract — non-system writes to this field are stripped on **both** INSERT and UPDATE (the value falls back to `defaultValue`), not just hidden in the UI (#2948 / #3043) |

‎content/docs/data-modeling/fields.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ These properties are available on all field types:
453453
454454
| Property | Type | Default | Description |
455455
| :--- | :--- | :--- | :--- |
456-
| `searchable` | `boolean` | `false` | Include in full-text search |
456+
| `searchable` | `boolean` | `false` | Search this field when the object declares no `searchableFields` — any type but `secret` / `password` / `vector`; a declared `searchableFields` is the whole set |
457457
| `externalId` | `boolean` | `false` | Not read: nothing matches on it. A seed dataset names its match field in its own `externalId`, an import in its mapping's `upsertKey` |
458458
459459
Database indexes are declared at the object level in the object's `indexes[]` array — there is no per-field `index` flag. See [Indexing](/docs/data-modeling/indexing).

‎content/docs/data-modeling/queries.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,8 @@ Only `query` and `fields` are implemented. The engine expands `search` into a dr
538538
<Callout type="info">
539539
`fields` (and its query-parameter spelling, `?searchFields=` / `?$searchFields=`) can only
540540
**narrow** the scan within the server-resolved searchable set — the object's declared
541-
`searchableFields`, or a text-like auto-default when none are declared. Over the
541+
`searchableFields`, or, when none are declared, a text-like auto-default plus every
542+
field marked `searchable: true` except `secret` / `password` / `vector`. Over the
542543
REST/protocol ingress a name outside that set is `400 INVALID_FIELD`, with distinct
543544
messages for a field that does not exist and a real field that is not searchable. An
544545
override that names no field at all (`?$searchFields=`, `?$searchFields=,`, an empty

‎content/docs/data-modeling/schema-design.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ enable: {
8787
### Global search — `searchable` + `searchableFields`
8888

8989
`searchable: true` includes an object's records in global search and the record
90-
picker. By default the search matches the name/title field plus short-text fields.
90+
picker. By default the search matches the name/title field plus short-text fields, and
91+
every field marked `searchable: true` (any type but `secret` / `password` / `vector`).
9192
To control exactly which fields are matched, set `searchableFields` on the object
9293
(ADR-0061):
9394

@@ -102,7 +103,8 @@ To control exactly which fields are matched, set `searchableFields` on the objec
102103
Queries then pass a top-level `$search` parameter to match across `searchableFields`
103104
(e.g. `{ $search: 'acme' }`); a request may narrow the set with
104105
`$searchFields`. When `searchableFields` is unset, search falls back to the
105-
name/title field plus short-text fields. When neither leaves a stored field to scan — a
106+
name/title field plus short-text fields and the fields marked `searchable: true`; a
107+
declared `searchableFields` is the whole set, and no field's own flag is read. When neither leaves a stored field to scan — a
106108
junction object of lookups with a `formula` title, say — a list search is refused
107109
`400 INVALID_FIELD` rather than answered with every row, and global search skips the
108110
object; mirror the title onto a stored text field, as below.

‎content/docs/protocol/objectql/query-syntax.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1186,7 +1186,8 @@ const structured: QueryAST = {
11861186
Field resolution is server-side and never client-trusted: the requested fields
11871187
(`searchFields`, or `search.fields` in the structured form) are **intersected** with the
11881188
object's declared `searchableFields` (or, absent those, an auto-default of the name field
1189-
plus short-text/enum fields), so naming a field outside that set can never widen the
1189+
plus short-text/enum fields and every field marked `searchable: true` except `secret` /
1190+
`password` / `vector`), so naming a field outside that set can never widen the
11901191
search — and over the REST/protocol ingress it is `400 INVALID_FIELD` outright (#4254),
11911192
because the engine-side intersection alone used to drop the unknown name and fall back to
11921193
scanning the full searchable set. Internal callers reaching `engine.find()` directly keep

‎content/docs/references/data/field.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const result = CurrencyConfigSchema.parse(data);
5757
| **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. |
5858
| **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. 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. |
5959
| **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. |
60-
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |
60+
| **searchable** | `boolean` | optional (default: `false`) | Search this field when its object declares no `searchableFields`. The set `search` scans, and the set a `$searchFields` override may name, is then the type-based default (the text-like and select columns) plus every field marked `searchable: true`, of any type except `secret`, `password` and `vector`, which stay excluded (a virtual `formula` field has no stored column, so it is never scanned either). `false`, the default, does not remove a field the type-based default already scans; to choose the set, declare `searchableFields`. When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). |
6161
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. 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. |
6262
| **unique** | `boolean \| 'global' \| 'organization'` | optional | 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'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. |
6363
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, 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. |

0 commit comments

Comments
 (0)