From 574bc7eeb4c12ee68c57d37dffc3ef4707cdce61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:36:57 +0000 Subject: [PATCH 1/5] feat(spec): a field marked searchable: true joins the search set when the object declares no searchableFields resolveSearchFieldResolution's auto branch now admits every field whose own `searchable` is true, of any type except secret / password / vector (SEARCH_FLAG_EXCLUDED_TYPES) and a virtual field. A declared searchableFields stays the whole set. The $searchFields ingress gate and the lint rule read the same resolution; the lint field-meta projection now carries `searchable`, and the refusal prose that called a non-text type unscannable names the opt-in instead. Claude-Session: https://claude.ai/code/session_01ADHkmSR2TvJTmoYyVtcQv9 Co-authored-by: Claude --- packages/lint/src/rule-explanations.ts | 14 ++-- .../lint/src/validate-searchable-fields.ts | 21 ++++- .../src/protocol-query-validation.ts | 22 ++++- packages/spec/src/data/field.zod.ts | 16 +++- packages/spec/src/data/search-fields.ts | 84 +++++++++++++++++-- 5 files changed, 139 insertions(+), 18 deletions(-) diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index eaa9868844a..e6118773f9d 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -2480,14 +2480,18 @@ const SEARCHABLE_FIELD_UNSEARCHABLE_EXPLANATION: RuleExplanation = { covers: 'which searchableFields entries the runtime refuses', paragraphs: [ SEARCH_FIELDS_ECHO, - // Held equal to `SEARCHABLE_TEXTUAL_TYPES` + `SEARCHABLE_ENUM_TYPES` by the rule's tests. + // Held equal to `SEARCHABLE_TEXTUAL_TYPES` + `SEARCHABLE_ENUM_TYPES`, and its flag exclusions to + // `SEARCH_FLAG_EXCLUDED_TYPES`, by the rule's tests. 'The allowed set is computed by the function the ingress and the engine consult ' + '(`resolveSearchFieldResolution`): the object\'s declared `searchableFields` when it declares ' + 'them, otherwise the AUTO-DEFAULT set — its text-like columns (text / email / phone / url / ' + - 'autonumber / textarea / markdown / select), never a system/audit column or a hidden field. A ' + - '`lookup` or `master_detail` column stores only the referenced record\'s id, so it is never a ' + - 'keyword target: to search by the related record\'s title, mirror it onto a stored text field ' + - 'and declare that. Declaring `searchableFields` on the object chooses its set explicitly, and a ' + + 'autonumber / textarea / markdown / select), never a system/audit column or a hidden field, plus ' + + 'every field the object marks `searchable: true`, of any type but `secret` / `password` / ' + + '`vector`. A `lookup` or `master_detail` column stores only the referenced record\'s id, so the ' + + 'default never picks it and marking it `searchable: true` scans that id, never the title: to ' + + 'search by the related record\'s title, mirror it onto a stored text field and declare that. ' + + 'Declaring `searchableFields` on the object chooses its set explicitly (a field\'s own ' + + '`searchable` is then not read), and a ' + 'view then narrows that set, never widens it (ADR-0061). The verdict quotes at most three names ' + 'of a declared set.', VIRTUAL_FIELD_STORAGE, diff --git a/packages/lint/src/validate-searchable-fields.ts b/packages/lint/src/validate-searchable-fields.ts index 1343dd89e12..60eeded5198 100644 --- a/packages/lint/src/validate-searchable-fields.ts +++ b/packages/lint/src/validate-searchable-fields.ts @@ -148,6 +148,7 @@ import { isCredentialFieldType, isVirtualSearchField, SEARCH_AUTO_EXCLUDED_FIELDS, + SEARCH_FLAG_EXCLUDED_TYPES, type SearchFieldMeta, } from '@objectstack/spec/data'; import { recordsOf, suggestName } from './object-graph.js'; @@ -258,6 +259,10 @@ function declaredFieldTarget(obj: AnyRec): ObjectSearchTarget | null { metas[n] = { type: typeof f.type === 'string' ? f.type : undefined, hidden: f.hidden === true, + // The field's own opt-in: with no `searchableFields` declared, the + // runtime resolution admits a field marked `searchable: true` (ruling + // record 6573569376), so a narrowing naming it is accepted there. + searchable: f.searchable === true, }; } if (names.size === 0) return null; @@ -515,10 +520,19 @@ export function checkSearchableFieldList( } else if (meta?.hidden) { why = 'hidden'; } else if (typeof meta?.type === 'string') { - why = `of type '${meta.type}', which 'search' cannot scan`; + // The runtime admits a field of any type its author marks + // `searchable: true`, except the hard exclusions — so "cannot scan" is + // said only of those, and every other type names the opt-in it lacks. + why = SEARCH_FLAG_EXCLUDED_TYPES.has(meta.type) + ? `of type '${meta.type}', which 'search' never scans, even marked \`searchable: true\`` + : `of type '${meta.type}' and not marked \`searchable: true\``; } else { continue; // no readable type — unresolvable, not wrong (ADR-0072 D1) } + // Whether the field's own opt-in would admit it — every arm above, a + // system name and `hidden` included, since the flag is asked before the + // auto-default's exclusions (`@objectstack/spec/data` `search-fields.ts`). + const flaggable = typeof meta?.type === 'string' && !SEARCH_FLAG_EXCLUDED_TYPES.has(meta.type); findings.push({ severity: 'error', rule: SEARCHABLE_FIELD_UNSEARCHABLE, @@ -535,7 +549,10 @@ export function checkSearchableFieldList( hint: isReference ? `Drop "${name}" from this view; to search by the related record's title, mirror it onto ` + `a stored text field here and declare that instead` - : `Drop "${name}" from this view, or target a text-like field instead`, + : flaggable + ? `Mark "${name}" \`searchable: true\` on "${objectName}", drop it from this view, or target ` + + `a text-like field instead` + : `Drop "${name}" from this view, or target a text-like field instead`, }); } return findings; diff --git a/packages/metadata-protocol/src/protocol-query-validation.ts b/packages/metadata-protocol/src/protocol-query-validation.ts index 471290bebed..4e5ae388fc8 100644 --- a/packages/metadata-protocol/src/protocol-query-validation.ts +++ b/packages/metadata-protocol/src/protocol-query-validation.ts @@ -49,7 +49,7 @@ import type { ObjectStackProtocolImplementation } from './protocol.js'; import { FILTER_LOGICAL_KEYS, ODATA_SPELLING, QUERY_PARAM_NEAR_MISS, invalidSortError, isObjectOfDisabledPackage, isPlainRecord, nearMissKey, objectPackageDisabledError, quotedQueryValue, suggestFieldName } from './protocol.js'; import { objectNotFoundError, relationConditionSites } from '@objectstack/core'; import { collectInternalWriteResponseFields } from './write-response-internal-fields.js'; -import { AggregationFunction, DateGranularity, REFERENCE_VALUE_TYPES, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS, apiExposureDenialReason, canServeApiOperation, classifyDottedFilterHead, effectiveOperationsArray, isCredentialFieldType, isFilterAST, isVirtualSearchField, parseFilterAST, referenceTargetOf, resolveEffectiveApiMethods, resolveSearchFieldResolution } from '@objectstack/spec/data'; +import { AggregationFunction, DateGranularity, REFERENCE_VALUE_TYPES, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS, SEARCH_FLAG_EXCLUDED_TYPES, apiExposureDenialReason, canServeApiOperation, classifyDottedFilterHead, effectiveOperationsArray, isCredentialFieldType, isFilterAST, isVirtualSearchField, parseFilterAST, referenceTargetOf, resolveEffectiveApiMethods, resolveSearchFieldResolution } from '@objectstack/spec/data'; /** * [#20802] The nested-relation spelling of a dotted relation path, as the @@ -1026,17 +1026,31 @@ export function assertSearchFieldsAreSearchable( + "'search' scans — a field outside it cannot be a search target until it is added there."; } else { const meta = gate.fields[first]; + // The field's own `searchable: true` admits it whatever its name, + // `hidden` or type, except the hard exclusions — the resolution above + // asks it before the default's own exclusions (ruling record + // 6573569376). So the reason names the opt-in the field lacks, and the + // remedy offers it wherever it would work. + const hardExcluded = !!meta && typeof meta.type === 'string' && SEARCH_FLAG_EXCLUDED_TYPES.has(meta.type); const why = !meta || SEARCH_AUTO_EXCLUDED_FIELDS.has(first) ? 'a system/audit column, which the default never includes' : meta.hidden ? 'hidden' - : `type '${meta.type}'`; + : hardExcluded + ? `type '${meta.type}', which 'search' never scans even when marked 'searchable: true'` + : `type '${meta.type}'`; + const flaggable = !!meta && typeof meta.type === 'string' && !hardExcluded; detail = `Field '${first}' on object '${object}' is not searchable` + (offenders.length > 1 ? ` (also: ${offenders.slice(1).join(', ')})` : '') + `. With no 'searchableFields' declared, 'search' scans the text-like columns ` - + `(${[...SEARCHABLE_TEXTUAL_TYPES, ...SEARCHABLE_ENUM_TYPES].join(' / ')}), and '${first}' is ` + + `(${[...SEARCHABLE_TEXTUAL_TYPES, ...SEARCHABLE_ENUM_TYPES].join(' / ')}) and every field ` + + `marked 'searchable: true', and '${first}' is ` + why - + ". Declare 'searchableFields' on the object to choose the searchable set explicitly."; + + (flaggable ? ", not marked 'searchable: true'" : '') + + (flaggable + ? ". Mark the field 'searchable: true', or declare 'searchableFields' on the object to " + + 'choose the searchable set explicitly.' + : ". Declare 'searchableFields' on the object to choose the searchable set explicitly."); } const err: any = new Error(detail); err.code = 'INVALID_FIELD'; diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 641fae6516c..672512fe5da 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1403,7 +1403,21 @@ export const FieldSchema = lazySchema(() => { notNull: z.boolean().optional().describe('Emit a physical NOT NULL on the column (ADR-0113). Absent = the column stays nullable even under `required: true` — the write contract is enforced at the engine, the only sanctioned write path, not by the database. Declaring this over existing null rows is a destructive migration gated by the schema-drift ceremony. Incompatible with `requiredWhen` (a conditional contract cannot be an unconditional column constraint).'), }).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'), + /** + * The field's own search opt-in, read by `resolveSearchFieldResolution` + * (`search-fields.ts`) on its auto branch only — ruling record 6573569376, + * letter A: honoured, not retired (ADR-0049). The `$searchFields` + * ingress allow-list is the same resolved set, so it admits what this key + * admits. + */ + searchable: z.boolean().default(false).describe( + '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). When the object declares `searchableFields`, that list ' + + 'is the whole search set and this flag is not read (ADR-0061).', + ), /** * Multi-value empty representation (#9447, maintainer ruling 2026-08-18): * an emptied multi-value lookup reads back as `[]`, never `null`. This diff --git a/packages/spec/src/data/search-fields.ts b/packages/spec/src/data/search-fields.ts index cdb3e64302a..d45c69a63f3 100644 --- a/packages/spec/src/data/search-fields.ts +++ b/packages/spec/src/data/search-fields.ts @@ -34,8 +34,18 @@ * * Resolution precedence (server-side, never client-trusted): * 1. the object's declared `searchableFields` (filtered to entries that exist - * AND have a stored column to scan — #6674) - * 2. an auto-default: the display/name field + short-text and enum fields. + * AND have a stored column to scan — #6674) — the WHOLE set when it is + * non-empty; no field's own `searchable` flag is read on this branch + * 2. an auto-default: the display/name field + short-text and enum fields, + * plus every field marked `searchable: true` of any type except the hard + * exclusions ({@link SEARCH_FLAG_EXCLUDED_TYPES}) and a virtual field. + * + * The flag's half of (2) is the maintainer's ruling on #29008 (ruling record + * 6573569376, letter A): `FieldSchema.searchable` is honoured, not retired — + * it was a declared, defaulted key that nothing read (ADR-0049), so a field + * the served schema called `searchable: true` was neither scanned nor + * admitted as a `$searchFields` override. ADR-0061 D2's "Field-level + * `searchable` stays pruned" predates the ruling and no longer describes it. * * An explicit `$searchFields` override is then INTERSECTED with that allowed * set — it can narrow the scan, never widen it (`resolveSearchFields`). @@ -45,6 +55,15 @@ export interface SearchFieldMeta { type?: string; hidden?: boolean; + /** + * The field's own search opt-in (`FieldSchema.searchable`). Read on the + * AUTO branch only: with no `searchableFields` declared, `true` brings the + * field into the set whatever its type, except + * {@link SEARCH_FLAG_EXCLUDED_TYPES} and a virtual field. `false` and absent + * are one answer — the key defaults to `false`, so it can widen the + * auto-default and never narrows it. + */ + searchable?: boolean; options?: Array<{ label?: string; value: unknown } | string> | unknown; } @@ -95,6 +114,36 @@ export const SEARCH_AUTO_EXCLUDED_TYPES: ReadonlySet = new Set([ */ export const SEARCH_VIRTUAL_TYPES: ReadonlySet = new Set(['formula']); +/** + * The HARD exclusions of the field-level opt-in: types a `searchable: true` + * never brings into the auto-default (ruling record 6573569376, letter A). + * + * The flag otherwise admits a field of ANY type — a `code`, `html`, `lookup` or + * `json` column is a stored value a `$icontains` scan can match, and the author + * asked for it, which is the same choice a declared `searchableFields` entry + * makes. These three are refused it anyway, for reasons the author cannot + * waive: + * + * - `secret` / `password` are CREDENTIALS. The engine withholds every + * credential column from the scan whatever the metadata says (ADR-0100), so + * admitting one here would put a name in the #4254 ingress allow-list that + * the engine never scans — the "narrower search answered with a wider one" + * the ingress exists to refuse. `search-fields.test.ts` holds every credential + * type (`MASKED_ON_READ_FIELD_TYPES`) inside this set, as it does for + * `SEARCH_AUTO_EXCLUDED_TYPES`. + * - `vector` is an embedding array: a substring match over it means nothing, + * and it is the heaviest column a scan could touch. + * + * A virtual field ({@link isVirtualSearchField}) is kept out on top of these, + * by the storage fact rather than by this set: it has no column to scan. + * + * Pinned to exactly these three in `search-fields.test.ts`. Exported so the + * two enforcement faces that word a refusal for a flagged field (the ingress + * gate and the linter) say "never, even marked" from this same set instead of + * carrying their own type literal. + */ +export const SEARCH_FLAG_EXCLUDED_TYPES: ReadonlySet = new Set(['secret', 'password', 'vector']); + /** * Is this field virtual — computed on read, with no stored column for `search` * to scan? The ONE judgment behind the refusal, exported so the REST ingress @@ -124,10 +173,31 @@ export interface SearchFieldResolutionOptions { displayField?: string; } +/** + * Does the field's own `searchable: true` bring it into the auto-default? + * + * Asked BEFORE the auto-default's own three exclusions (system name, hidden, + * type), because it answers a different question: those say what the default + * does not GUESS, and the flag is the author saying it outright — the per-field + * twin of naming the field in `searchableFields`, which bypasses the same + * three. What the flag cannot do is name a type the platform never scans + * ({@link SEARCH_FLAG_EXCLUDED_TYPES}) or a field with no stored column + * ({@link isVirtualSearchField}). An unreadable `type` is not admitted: the + * hard exclusion cannot be judged without it (fail closed), and the lint + * mirror's stub metadata (`{}`) for registry-injected columns stays out. + */ +function admittedBySearchableFlag(meta: SearchFieldMeta): boolean { + return meta.searchable === true + && typeof meta.type === 'string' + && !SEARCH_FLAG_EXCLUDED_TYPES.has(meta.type) + && !isVirtualSearchField(meta); +} + function autoDefaultFields(fields: Record, displayField?: string): string[] { const names = Object.keys(fields).filter((f) => { - if (SEARCH_AUTO_EXCLUDED_FIELDS.has(f)) return false; const meta = fields[f]; + if (meta && admittedBySearchableFlag(meta)) return true; + if (SEARCH_AUTO_EXCLUDED_FIELDS.has(f)) return false; if (!meta || meta.hidden) return false; const t = meta.type; if (!t) return false; @@ -196,9 +266,11 @@ function autoDefaultFields(fields: Record, displayField * `declared` when the object's `searchableFields` (filtered to entries the * engine can actually scan) is non-empty, `auto` otherwise. The source matters * to the #4254 ingress gate because the two rejections it explains differ: a - * field missing from a declared list is fixed by adding it there, while a field - * the auto-default skips is excluded by its TYPE and needs `searchableFields` - * declared to become a target. + * field missing from a declared list is fixed by adding it there (its own + * `searchable: true` is not read on that branch), while a field the + * auto-default skips is excluded by its name, `hidden` or TYPE and becomes a + * target by being marked `searchable: true` — unless its type is one of + * {@link SEARCH_FLAG_EXCLUDED_TYPES} — or by `searchableFields` being declared. * * TWO filters run on the declared branch, and they are different claims: * From 6eb560a56dc43f185fd21eb11688991af5ac0b78 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:43:12 +0000 Subject: [PATCH 2/5] test(spec,lint,rest): pin field.searchable joining the auto-default search set Claude-Session: https://claude.ai/code/session_01ADHkmSR2TvJTmoYyVtcQv9 Co-authored-by: Claude --- .../src/validate-searchable-fields.test.ts | 75 +++++++- .../lint/src/validate-searchable-fields.ts | 2 +- ...-search-field-searchable-flag-door.test.ts | 168 ++++++++++++++++++ packages/spec/src/data/search-fields.test.ts | 137 ++++++++++++++ 4 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 packages/rest/src/data-search-field-searchable-flag-door.test.ts diff --git a/packages/lint/src/validate-searchable-fields.test.ts b/packages/lint/src/validate-searchable-fields.test.ts index 65e284963f7..832eee01dba 100644 --- a/packages/lint/src/validate-searchable-fields.test.ts +++ b/packages/lint/src/validate-searchable-fields.test.ts @@ -5,6 +5,7 @@ import { resolveSearchFields, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES, + SEARCH_FLAG_EXCLUDED_TYPES, SEARCH_VIRTUAL_TYPES, } from '@objectstack/spec/data'; import { @@ -822,7 +823,76 @@ describe('validateSearchableFields — objectstack-ui SKILL.md parity (#6675)', const refused = validateSearchableFields(viewStack(['account_id'], noDeclaration)); expect(refused).toHaveLength(1); expect(refused[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); - expect(refused[0].message).toContain("of type 'lookup', which 'search' cannot scan"); + // [#29008] The verdict names the opt-in the field lacks: the runtime now + // admits a field of any type marked `searchable: true` (ruling record + // 6573569376), so "which 'search' cannot scan" stopped being true. + expect(refused[0].message).toContain("of type 'lookup' and not marked `searchable: true`"); + }); +}); + +/** + * [#29008] The field's own `searchable: true` — the lint resolves the allowed + * set with the runtime's function, so its field-meta projection must carry the + * flag, or a narrowing the ingress accepts would be refused here. + */ +describe('[#29008] validateSearchableFields — a field marked searchable: true', () => { + const noteStack = (fields: Record, viewSet: string[], objectSet?: string[]) => ({ + objects: [{ name: 'dev_note', ...(objectSet ? { searchableFields: objectSet } : {}), fields }], + views: [{ + name: 'dev_note', + objectName: 'dev_note', + list: { label: 'All', type: 'grid', data: { provider: 'object', object: 'dev_note' }, columns: ['name'] }, + listViews: { + code: { + label: 'Code', type: 'grid', data: { provider: 'object', object: 'dev_note' }, + columns: ['name'], searchableFields: viewSet, + }, + }, + }], + }); + const flagged = { + name: { type: 'text' }, + snippet: { type: 'code', searchable: true }, + body: { type: 'html', searchable: true }, + }; + + it('with no object declaration, a view narrowing to flagged `code` / `html` fields lints clean', () => { + expect(validateSearchableFields(noteStack(flagged, ['snippet', 'body']))).toEqual([]); + }); + + it('CONTROL — the same fields unflagged are refused, and the fix line offers the flag', () => { + const refused = validateSearchableFields(noteStack( + { name: { type: 'text' }, snippet: { type: 'code' } }, + ['snippet'], + )); + expect(refused).toHaveLength(1); + expect(refused[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE); + expect(refused[0].message).toContain("of type 'code' and not marked `searchable: true`"); + expect(refused[0].hint).toBe( + 'Mark "snippet" `searchable: true` on "dev_note", drop it from this view, or target a text-like field instead', + ); + }); + + it('CONTROL — a flagged `vector` stays refused, and the verdict says the flag cannot admit it', () => { + const refused = validateSearchableFields(noteStack( + { name: { type: 'text' }, embedding: { type: 'vector', searchable: true } }, + ['embedding'], + )); + expect(refused).toHaveLength(1); + expect(refused[0].message).toContain("of type 'vector', never scanned even with `searchable: true`"); + expect(refused[0].hint).toBe('Drop "embedding" from this view, or target a text-like field instead'); + }); + + it('CONTROL — an object that DECLARES its set ignores the flag: outside the declaration is refused', () => { + const refused = validateSearchableFields(noteStack(flagged, ['snippet'], ['name'])); + expect(refused).toHaveLength(1); + expect(refused[0].message).toContain('outside the declared searchableFields (name)'); + }); + + it('the allowed set is the runtime\'s: indexObjectSearchTargets carries the flag into the resolution', () => { + const target = indexObjectSearchTargets(noteStack(flagged, ['snippet'])).get('dev_note'); + expect(target?.fields.snippet).toEqual({ type: 'code', hidden: false, searchable: true }); + expect(target?.fields.name).toEqual({ type: 'text', hidden: false, searchable: false }); }); }); @@ -1239,6 +1309,9 @@ describe('[#22161] one-line verdicts — the three searchable ids', () => { // the virtual-type claim it states are held to their spec sources here. const types = [...SEARCHABLE_TEXTUAL_TYPES, ...SEARCHABLE_ENUM_TYPES].join(' / '); expect(explanationOf(SEARCHABLE_FIELD_UNSEARCHABLE)).toContain(`(${types})`); + // [#29008] …and the opt-in's hard exclusions, as the explanation spells them. + const flagExcluded = [...SEARCH_FLAG_EXCLUDED_TYPES].map((t) => `\`${t}\``).join(' / '); + expect(explanationOf(SEARCHABLE_FIELD_UNSEARCHABLE)).toContain(`of any type but ${flagExcluded}`); expect([...SEARCH_VIRTUAL_TYPES]).toEqual(['formula']); expect(explanationOf(SEARCHABLE_FIELD_UNSEARCHABLE)).toContain('`formula` alone'); }); diff --git a/packages/lint/src/validate-searchable-fields.ts b/packages/lint/src/validate-searchable-fields.ts index 60eeded5198..401e6379d7f 100644 --- a/packages/lint/src/validate-searchable-fields.ts +++ b/packages/lint/src/validate-searchable-fields.ts @@ -524,7 +524,7 @@ export function checkSearchableFieldList( // `searchable: true`, except the hard exclusions — so "cannot scan" is // said only of those, and every other type names the opt-in it lacks. why = SEARCH_FLAG_EXCLUDED_TYPES.has(meta.type) - ? `of type '${meta.type}', which 'search' never scans, even marked \`searchable: true\`` + ? `of type '${meta.type}', never scanned even with \`searchable: true\`` : `of type '${meta.type}' and not marked \`searchable: true\``; } else { continue; // no readable type — unresolvable, not wrong (ADR-0072 D1) diff --git a/packages/rest/src/data-search-field-searchable-flag-door.test.ts b/packages/rest/src/data-search-field-searchable-flag-door.test.ts new file mode 100644 index 00000000000..e003f80f5c8 --- /dev/null +++ b/packages/rest/src/data-search-field-searchable-flag-door.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29008] A field marked `searchable: true` is searched, and a `$searchFields` + * override naming it is accepted, when its object declares no + * `searchableFields` — over the Data API door on a real `SqlDriver` (SQLite), + * the harness of `data-search-fields-credential-door.test.ts`. + * + * Ruling record 6573569376 (letter A): with no declaration, the set `search` + * scans is the type-based auto-default plus every field marked + * `searchable: true`, of any type except `secret` / `password` / `vector`, and + * the ingress allow-list is the same set. Before, the card's three requests + * answered `[]`, `[]`, then `400 INVALID_FIELD` "is not searchable … 'snippet' + * is type 'code'" while the served schema said `searchable: true`. + * + * Pinned here: the card's three requests (the row comes back through `snippet` + * and through `body`, and `$searchFields=snippet` answers 200 with it), and the + * controls the ruling keeps — an unflagged `code` field is still neither + * scanned nor accepted, a flagged `secret` is still refused, and an object + * that DECLARES `searchableFields` keeps its declaration as the whole set. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +/** No declaration: the flag decides. */ +const AUTO = 'rest_search_flag_auto_29008'; +/** The same fields, under a declaration that names only `name`. */ +const DECLARED = 'rest_search_flag_declared_29008'; + +const FIELDS = { + name: { name: 'name', type: 'text' as const }, + snippet: { name: 'snippet', type: 'code' as const, searchable: true }, + body: { name: 'body', type: 'html' as const, searchable: true }, + // CONTROL — a `code` field NOT marked: the auto-default still skips its type. + scratch: { name: 'scratch', type: 'code' as const }, + // CONTROL — a hard exclusion: the flag cannot admit a credential. + api_key: { name: 'api_key', type: 'secret' as const, searchable: true }, +}; + +const ROWS = [ + { id: 'r1', name: 'fs2 row', snippet: 'select QQCODE from x', body: '

holding QQHTML

', scratch: 'QQSCRATCH' }, + { id: 'r2', name: 'other row', snippet: 'select 1', body: '

nothing

', scratch: 'nothing' }, +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +describe('[#29008] field.searchable: true on the Data API door — sqlite', () => { + let engine: ObjectQL; + let driver: any; + const reads = { n: 0 }; + let list: (object: string, query: Record) => Promise<{ status: number; body: any }>; + + beforeAll(async () => { + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject({ name: AUTO, label: 'Search flag auto 29008', fields: FIELDS } as any); + engine.registry.registerObject({ + name: DECLARED, label: 'Search flag declared 29008', searchableFields: ['name'], fields: FIELDS, + } as any); + await engine.syncSchemas(); + // A real `single` install's Default Organization (ADR-0131 D9, D1). + await installDefaultOrganization(engine); + for (const object of [AUTO, DECLARED]) { + for (const row of ROWS) await engine.insert(object, row as any); + } + + const watched = new Set([AUTO, DECLARED]); + for (const verb of ['find', 'findOne', 'count', 'aggregate'] as const) { + const real = driver[verb].bind(driver); + driver[verb] = (o: string, ...rest: unknown[]) => { if (watched.has(o)) reads.n += 1; return real(o, ...rest); }; + } + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const getRoute = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object'); + expect(getRoute, 'GET /api/v1/data/:object').toBeDefined(); + list = async (object, q) => { + const res = makeRes(); + // What a querystring carries: strings. + await getRoute!.handler({ params: { object }, query: { $orderby: 'name', ...q }, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + const names = (body: any) => (body?.records ?? []).map((r: any) => r.name); + + it('the card\'s three requests: found through `snippet`, through `body`, and `$searchFields=snippet` is 200', async () => { + const byCode = await list(AUTO, { $search: 'QQCODE' }); + expect(byCode.status, JSON.stringify(byCode.body)).toBe(200); + expect(names(byCode.body)).toEqual(['fs2 row']); + + const byHtml = await list(AUTO, { $search: 'QQHTML' }); + expect(byHtml.status, JSON.stringify(byHtml.body)).toBe(200); + expect(names(byHtml.body)).toEqual(['fs2 row']); + + const narrowed = await list(AUTO, { $search: 'QQCODE', $searchFields: 'snippet' }); + expect(narrowed.status, JSON.stringify(narrowed.body)).toBe(200); + expect(names(narrowed.body)).toEqual(['fs2 row']); + // …and the narrowing is real: the html term is not in `snippet`. + const narrowedMiss = await list(AUTO, { $search: 'QQHTML', $searchFields: 'snippet' }); + expect(narrowedMiss.status, JSON.stringify(narrowedMiss.body)).toBe(200); + expect(names(narrowedMiss.body)).toEqual([]); + }); + + it('CONTROL — an unflagged `code` field is not scanned, and naming it is still 400 INVALID_FIELD before any read', async () => { + const unscanned = await list(AUTO, { $search: 'QQSCRATCH' }); + expect(unscanned.status, JSON.stringify(unscanned.body)).toBe(200); + expect(names(unscanned.body)).toEqual([]); + + const before = reads.n; + const refused = await list(AUTO, { $search: 'QQSCRATCH', $searchFields: 'scratch' }); + expect(refused.status, JSON.stringify(refused.body)).toBe(400); + expect(refused.body.code).toBe('INVALID_FIELD'); + expect(refused.body.error).toContain("Field 'scratch' on object '" + AUTO + "' is not searchable"); + expect(reads.n - before, 'the refusal precedes the driver').toBe(0); + }); + + it('CONTROL — a flagged `secret` stays excluded: naming it is 400 INVALID_FIELD before any read', async () => { + const before = reads.n; + const refused = await list(AUTO, { $search: 'QQKEY', $searchFields: 'api_key' }); + expect(refused.status, JSON.stringify(refused.body)).toBe(400); + expect(refused.body.code).toBe('INVALID_FIELD'); + expect(reads.n - before, 'the refusal precedes the driver').toBe(0); + }); + + it('CONTROL — a declared `searchableFields` stays the whole set: the flag is not read there', async () => { + const byCode = await list(DECLARED, { $search: 'QQCODE' }); + expect(byCode.status, JSON.stringify(byCode.body)).toBe(200); + expect(names(byCode.body)).toEqual([]); + + const byName = await list(DECLARED, { $search: 'fs2' }); + expect(byName.status, JSON.stringify(byName.body)).toBe(200); + expect(names(byName.body)).toEqual(['fs2 row']); + + const before = reads.n; + const refused = await list(DECLARED, { $search: 'QQCODE', $searchFields: 'snippet' }); + expect(refused.status, JSON.stringify(refused.body)).toBe(400); + expect(refused.body.code).toBe('INVALID_FIELD'); + expect(refused.body.error).toContain("The object declares 'searchableFields' (name)"); + expect(reads.n - before, 'the refusal precedes the driver').toBe(0); + }); +}); diff --git a/packages/spec/src/data/search-fields.test.ts b/packages/spec/src/data/search-fields.test.ts index a10ce64e89b..ffd2c22cf12 100644 --- a/packages/spec/src/data/search-fields.test.ts +++ b/packages/spec/src/data/search-fields.test.ts @@ -7,6 +7,7 @@ import { resolveSearchFields, SEARCH_AUTO_EXCLUDED_FIELDS, SEARCH_AUTO_EXCLUDED_TYPES, + SEARCH_FLAG_EXCLUDED_TYPES, SEARCH_VIRTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES, @@ -168,6 +169,7 @@ describe('search type vocabularies ⊆ FieldType', () => { ['SEARCHABLE_ENUM_TYPES', SEARCHABLE_ENUM_TYPES], ['SEARCH_AUTO_EXCLUDED_TYPES', SEARCH_AUTO_EXCLUDED_TYPES], ['SEARCH_VIRTUAL_TYPES', SEARCH_VIRTUAL_TYPES], + ['SEARCH_FLAG_EXCLUDED_TYPES', SEARCH_FLAG_EXCLUDED_TYPES], ]; it.each(vocabularies)('%s has no ghost members', (name, set) => { @@ -397,3 +399,138 @@ describe('SEARCH_VIRTUAL_TYPES is a storage fact', () => { ).toEqual({ allowed: ['name', 'created_at'], source: 'declared' }); }); }); + +// --------------------------------------------------------------------------- +// [#29008] A field's own `searchable: true` is honoured (ruling record +// 6573569376, letter A). +// +// The key was declared and defaulted on FieldSchema, stored and served — and +// read by nothing: `$search` skipped a `code` / `html` field marked searchable, +// and `$searchFields` naming it answered 400 "is not searchable" while the +// served schema said `searchable: true`. Ruled: with no `searchableFields` +// declared, the set is the type-based auto-default PLUS every flagged field of +// any type except `secret` / `password` / `vector`; a declared +// `searchableFields` stays the whole set. The ingress allow-list is this same +// resolution, so admitting a name here is what makes `$searchFields` accept it. +// --------------------------------------------------------------------------- +describe('field.searchable: true joins the auto-default set — never a declared one', () => { + // The card's reproduction: two non-default types marked searchable, and no + // `searchableFields` on the object. + const repro = { + name: { type: 'text' }, + snippet: { type: 'code', searchable: true }, + body: { type: 'html', searchable: true }, + }; + + it('the reproduction: `code` and `html` fields marked searchable resolve into the set', () => { + expect(resolveSearchFieldResolution({ fields: repro })).toEqual({ + allowed: ['name', 'snippet', 'body'], + source: 'auto', + }); + expect(resolveSearchFields({ fields: repro })).toEqual(['name', 'snippet', 'body']); + }); + + it('a `$searchFields` override naming a flagged field narrows to it instead of falling back', () => { + expect(resolveSearchFields({ fields: repro, requestedFields: 'snippet' })).toEqual(['snippet']); + expect(resolveSearchFields({ fields: repro, requestedFields: ['body', 'name'] })).toEqual(['body', 'name']); + }); + + it('CONTROL — the same fields NOT flagged stay out, exactly as before', () => { + const unflagged = { name: { type: 'text' }, snippet: { type: 'code' }, body: { type: 'html' } }; + expect(resolveSearchFieldResolution({ fields: unflagged })).toEqual({ allowed: ['name'], source: 'auto' }); + // …and an override naming one is intersected away (the ingress refuses it + // before this runs). + expect(resolveSearchFields({ fields: unflagged, requestedFields: 'snippet' })).toEqual(['name']); + }); + + it.each([...SEARCH_FLAG_EXCLUDED_TYPES])('CONTROL — a flagged `%s` field stays excluded (hard exclusion)', (t) => { + expect( + resolveSearchFieldResolution({ fields: { name: { type: 'text' }, probe: { type: t, searchable: true } } }), + `'${t}' is a hard exclusion but searchable: true admitted it`, + ).toEqual({ allowed: ['name'], source: 'auto' }); + }); + + it('CONTROL — a flagged virtual (`formula`) field stays out: no stored column to scan', () => { + expect( + resolveSearchFieldResolution({ fields: { name: { type: 'text' }, total: { type: 'formula', searchable: true } } }), + ).toEqual({ allowed: ['name'], source: 'auto' }); + }); + + it('CONTROL — a declared `searchableFields` is the whole set and ignores the flag on other fields', () => { + expect( + resolveSearchFieldResolution({ fields: repro, searchableFields: ['name'] }), + ).toEqual({ allowed: ['name'], source: 'declared' }); + // …and the override cannot reach a flagged field outside the declaration. + expect(resolveSearchFields({ fields: repro, searchableFields: ['name'], requestedFields: 'snippet' })) + .toEqual(['name']); + }); + + it('CONTROL — a text field with `searchable` absent behaves exactly as today, and `false` is the same answer', () => { + // The key defaults to `false`, so after a parse every field carries it: + // `false` cannot mean "opt out", or every text field would leave the set. + for (const meta of [{ type: 'text' }, { type: 'text', searchable: false }, { type: 'text', searchable: true }]) { + expect(resolveSearchFieldResolution({ fields: { title: meta } }), JSON.stringify(meta)) + .toEqual({ allowed: ['title'], source: 'auto' }); + } + expect(resolveSearchFieldResolution({ fields: { amount: { type: 'number', searchable: false } } })) + .toEqual({ allowed: [], source: 'auto' }); + }); + + it('admits every other FieldType, the auto-excluded ones included, when flagged', () => { + const admissible = FieldType.options.filter( + (t) => !SEARCH_FLAG_EXCLUDED_TYPES.has(t) && !SEARCH_VIRTUAL_TYPES.has(t), + ); + // Not vacuous: types the default never guesses are in the domain. + expect(admissible).toEqual(expect.arrayContaining(['code', 'html', 'json', 'lookup', 'number', 'boolean'])); + for (const t of admissible) { + expect( + resolveSearchFieldResolution({ fields: { probe: { type: t, searchable: true } } }), + `searchable: true did not admit a '${t}' field`, + ).toEqual({ allowed: ['probe'], source: 'auto' }); + } + }); + + it('the flag is asked before the default\'s exclusions: a flagged hidden field and a flagged system name join', () => { + expect(resolveSearchFieldResolution({ + fields: { + name: { type: 'text' }, + internal_code: { type: 'text', hidden: true, searchable: true }, + owner_id: { type: 'lookup', searchable: true }, + // CONTROLS — the same exclusions without the flag still hold. + notes: { type: 'text', hidden: true }, + created_by: { type: 'lookup' }, + }, + })).toEqual({ allowed: ['name', 'internal_code', 'owner_id'], source: 'auto' }); + }); + + it('an unreadable type is not admitted even when flagged — the hard exclusion cannot be judged (fail closed)', () => { + expect(resolveSearchFieldResolution({ fields: { probe: { searchable: true } } })) + .toEqual({ allowed: [], source: 'auto' }); + }); + + it('the display field still only ORDERS: a flagged display field leads, field-map order otherwise', () => { + expect(resolveSearchFieldResolution({ + fields: { body: { type: 'html', searchable: true }, name: { type: 'text' }, snippet: { type: 'code', searchable: true } }, + displayField: 'snippet', + })).toEqual({ allowed: ['snippet', 'body', 'name'], source: 'auto' }); + }); +}); + +describe('SEARCH_FLAG_EXCLUDED_TYPES — the opt-in\'s hard exclusions', () => { + it('is exactly the three ruled types', () => { + expect([...SEARCH_FLAG_EXCLUDED_TYPES].sort()).toEqual(['password', 'secret', 'vector']); + }); + + // The #27822 reasoning one axis over: the REST ingress resolves this set over + // the UNFILTERED field map, while the engine withholds every credential + // column before it resolves. A credential type a flag could admit would pass + // the door and be withheld by the engine — a narrowing answered wider. + it.each(Object.keys(MASKED_ON_READ_FIELD_TYPES))('covers credential type %s', (type) => { + expect(SEARCH_FLAG_EXCLUDED_TYPES.has(type), `'${type}' is masked on read but searchable: true admits it`) + .toBe(true); + }); + + it('is inside SEARCH_AUTO_EXCLUDED_TYPES — the default never guessed these either', () => { + expect([...SEARCH_FLAG_EXCLUDED_TYPES].filter((t) => !SEARCH_AUTO_EXCLUDED_TYPES.has(t))).toEqual([]); + }); +}); From b4866372b8b2b68f711ef61c75a84e6f628292e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:47:48 +0000 Subject: [PATCH 3/5] docs(spec): describe field.searchable as the auto-branch opt-in; changeset and liveness row Claude-Session: https://claude.ai/code/session_01ADHkmSR2TvJTmoYyVtcQv9 Co-authored-by: Claude --- .changeset/29008-field-searchable-honoured.md | 19 +++++++++++++++++++ content/docs/api/data-api.mdx | 2 +- content/docs/data-modeling/field-types.mdx | 2 +- content/docs/data-modeling/fields.mdx | 2 +- content/docs/data-modeling/queries.mdx | 3 ++- content/docs/data-modeling/schema-design.mdx | 6 ++++-- .../docs/protocol/objectql/query-syntax.mdx | 3 ++- packages/spec/liveness/field.json | 4 ++-- 8 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 .changeset/29008-field-searchable-honoured.md diff --git a/.changeset/29008-field-searchable-honoured.md b/.changeset/29008-field-searchable-honoured.md new file mode 100644 index 00000000000..a458cf110d7 --- /dev/null +++ b/.changeset/29008-field-searchable-honoured.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/lint': minor +--- + +feat(spec): a field marked `searchable: true` is searched when its object declares no `searchableFields` + +Clause-②: yes (widening) +Metadata-protocol: semantic + +`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). + +- **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. +- **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. +- **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. +- **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. +- **New export.** `SEARCH_FLAG_EXCLUDED_TYPES` (`@objectstack/spec/data`) names the three hard exclusions. `SearchFieldMeta` gains the optional `searchable`. +- **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. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 5881a600f1d..abbf151a787 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -23,7 +23,7 @@ Query records with filtering, sorting, selection, and pagination. | `top` | query | Max records to return. No default — omitting it returns all matching records. | | `skip` | query | Offset | | `expand` | query | Comma-separated list of relations to eager-load. Must name a reference field (`lookup` / `master_detail` / `user` / `tree`) — otherwise `400 INVALID_FIELD`. | -| `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. | +| `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. | | `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`. | > **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. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index ebd87b97598..57e5a40bec4 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -1023,7 +1023,7 @@ These properties are available on **all** field types: | `required` | `boolean` | `false` | Whether the field is required | | `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) | | `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) | -| `searchable` | `boolean` | `false` | Include in search index | +| `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 | | `sortable` | `boolean` | `true` | Allow sorting by this field | | `hidden` | `boolean` | `false` | Hide from default views | | `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) | diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index 5c11f88f453..379c103669d 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -446,7 +446,7 @@ These properties are available on all field types: | Property | Type | Default | Description | | :--- | :--- | :--- | :--- | -| `searchable` | `boolean` | `false` | Include in full-text search | +| `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 | | `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` | 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). diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index d3676c199cc..1758af6e28a 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -538,7 +538,8 @@ Only `query` and `fields` are implemented. The engine expands `search` into a dr `fields` (and its query-parameter spelling, `?searchFields=` / `?$searchFields=`) can only **narrow** the scan within the server-resolved searchable set — the object's declared -`searchableFields`, or a text-like auto-default when none are declared. Over the +`searchableFields`, or, when none are declared, a text-like auto-default plus every +field marked `searchable: true` except `secret` / `password` / `vector`. Over the REST/protocol ingress a name outside that set is `400 INVALID_FIELD`, with distinct messages for a field that does not exist and a real field that is not searchable. An override that names no field at all (`?$searchFields=`, `?$searchFields=,`, an empty diff --git a/content/docs/data-modeling/schema-design.mdx b/content/docs/data-modeling/schema-design.mdx index 96e53579e3b..068d55a878e 100644 --- a/content/docs/data-modeling/schema-design.mdx +++ b/content/docs/data-modeling/schema-design.mdx @@ -87,7 +87,8 @@ enable: { ### Global search — `searchable` + `searchableFields` `searchable: true` includes an object's records in global search and the record -picker. By default the search matches the name/title field plus short-text fields. +picker. By default the search matches the name/title field plus short-text fields, and +every field marked `searchable: true` (any type but `secret` / `password` / `vector`). To control exactly which fields are matched, set `searchableFields` on the object (ADR-0061): @@ -102,7 +103,8 @@ To control exactly which fields are matched, set `searchableFields` on the objec Queries then pass a top-level `$search` parameter to match across `searchableFields` (e.g. `{ $search: 'acme' }`); a request may narrow the set with `$searchFields`. When `searchableFields` is unset, search falls back to the -name/title field plus short-text fields. When neither leaves a stored field to scan — a +name/title field plus short-text fields and the fields marked `searchable: true`; a +declared `searchableFields` is the whole set, and no field's own flag is read. When neither leaves a stored field to scan — a junction object of lookups with a `formula` title, say — a list search is refused `400 INVALID_FIELD` rather than answered with every row, and global search skips the object; mirror the title onto a stored text field, as below. diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index e0d83c431e8..45e05ca391b 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -1186,7 +1186,8 @@ const structured: QueryAST = { Field resolution is server-side and never client-trusted: the requested fields (`searchFields`, or `search.fields` in the structured form) are **intersected** with the object's declared `searchableFields` (or, absent those, an auto-default of the name field -plus short-text/enum fields), so naming a field outside that set can never widen the +plus short-text/enum fields and every field marked `searchable: true` except `secret` / +`password` / `vector`), so naming a field outside that set can never widen the search — and over the REST/protocol ingress it is `400 INVALID_FIELD` outright (#4254), because the engine-side intersection alone used to drop the unknown name and fall back to scanning the full searchable set. Internal callers reaching `engine.find()` directly keep diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index ee95fc61c10..c59dd43798e 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -407,8 +407,8 @@ }, "searchable": { "status": "live", - "evidence": "ui/packages/plugin-dashboard/src/WidgetConfigPanel.tsx (:458 in objectui, commit not pinned)", - "note": "LIVE via objectui renderer — the 2026-06 audit mis-classified as dead (renderer side not re-verified). Corrected after checking ../objectui." + "evidence": "packages/spec/src/data/search-fields.ts (admittedBySearchableFlag, the auto branch of resolveSearchFieldResolution); read by packages/objectql/src/search-filter.ts (the search expansion), packages/metadata-protocol/src/protocol-query-validation.ts (the $searchFields ingress allow-list) and packages/lint/src/validate-searchable-fields.ts", + "note": "Honoured since #29008 (ruling record 6573569376, letter A): with no object searchableFields, a field marked searchable: true joins the search set whatever its type, except secret / password / vector and a virtual formula field; a declared searchableFields stays the whole set and the flag is not read there. The row's earlier evidence, ui/packages/plugin-dashboard/src/WidgetConfigPanel.tsx, reads a dashboard WIDGET's searchable option, not this field key: before #29008 nothing read FieldSchema.searchable, as the card measured." }, "externalId": { "status": "dead", From 85ec00115a6b5179a99240050a1d9509638e2656 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:56:09 +0000 Subject: [PATCH 4/5] chore(spec): regenerate api-surface, export-origins and the field reference pages Claude-Session: https://claude.ai/code/session_01ADHkmSR2TvJTmoYyVtcQv9 Co-authored-by: Claude --- content/docs/references/data/field.mdx | 2 +- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 8701a10066e..6fac0c1a4fd 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -57,7 +57,7 @@ const result = CurrencyConfigSchema.parse(data); | **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`. | | **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. | | **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 | +| **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). When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | | **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. | | **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. | | **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. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 753c44d051e..2661dd897a0 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -226,7 +226,7 @@ const result = ApiMethod.parse(data); | **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`. | | **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. | | **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 | +| **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). When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | | **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. | | **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. | | **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. | @@ -596,7 +596,7 @@ const result = ApiMethod.parse(data); | **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`. | | **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. | | **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 | +| **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). When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | | **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. | | **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. | | **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. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 988021e4b23..f8692354215 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -60,7 +60,7 @@ Add a new field to an existing object | **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`. | | **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. | | **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 | +| **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). When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | | **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. | | **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. | | **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. | @@ -493,7 +493,7 @@ Add a new field to an existing object | **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`. | | **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. | | **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 | +| **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). When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | | **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. | | **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. | | **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. | diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 3d97852faa9..281d92834e2 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -672,6 +672,7 @@ "SEARCHABLE_TEXTUAL_TYPES (const)", "SEARCH_AUTO_EXCLUDED_FIELDS (const)", "SEARCH_AUTO_EXCLUDED_TYPES (const)", + "SEARCH_FLAG_EXCLUDED_TYPES (const)", "SEARCH_VIRTUAL_TYPES (const)", "SECRET_MASK (const)", "SINGLE_OPTION_TYPES (const)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index d11e4a2590a..8de407ccee6 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -660,6 +660,7 @@ "SEARCHABLE_TEXTUAL_TYPES": "src/data/search-fields.ts#SEARCHABLE_TEXTUAL_TYPES (const)", "SEARCH_AUTO_EXCLUDED_FIELDS": "src/data/search-fields.ts#SEARCH_AUTO_EXCLUDED_FIELDS (const)", "SEARCH_AUTO_EXCLUDED_TYPES": "src/data/search-fields.ts#SEARCH_AUTO_EXCLUDED_TYPES (const)", + "SEARCH_FLAG_EXCLUDED_TYPES": "src/data/search-fields.ts#SEARCH_FLAG_EXCLUDED_TYPES (const)", "SEARCH_VIRTUAL_TYPES": "src/data/search-fields.ts#SEARCH_VIRTUAL_TYPES (const)", "SECRET_MASK": "src/data/secret-mask.ts#SECRET_MASK (const)", "SINGLE_OPTION_TYPES": "src/data/field-value.zod.ts#SINGLE_OPTION_TYPES (const)", From f60ceb0b96264ef39edc520812d0f0f3696f842a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:10:39 +0000 Subject: [PATCH 5/5] docs(spec): say that searchable: false never removes a type-default field The `searchable` describe now says that `false`, the default, does not remove a field the type-based default already scans, and that choosing the set is what `searchableFields` is for. The `SearchFieldMeta.searchable` TSDoc already said so; the author-facing key now does too. The field reference pages are regenerated. Claude-Session: https://claude.ai/code/session_01ADHkmSR2TvJTmoYyVtcQv9 Co-authored-by: Claude --- content/docs/references/data/field.mdx | 2 +- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- packages/spec/src/data/field.zod.ts | 4 +++- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 6fac0c1a4fd..bf03acecff6 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -57,7 +57,7 @@ const result = CurrencyConfigSchema.parse(data); | **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`. | | **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. | | **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`) | 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). When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | +| **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). | | **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. | | **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. | | **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. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 2661dd897a0..6a9c6a58759 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -226,7 +226,7 @@ const result = ApiMethod.parse(data); | **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`. | | **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. | | **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`) | 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). When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | +| **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). | | **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. | | **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. | | **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. | @@ -596,7 +596,7 @@ const result = ApiMethod.parse(data); | **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`. | | **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. | | **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`) | 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). When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | +| **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). | | **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. | | **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. | | **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. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index f8692354215..143ef32fc64 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -60,7 +60,7 @@ Add a new field to an existing object | **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`. | | **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. | | **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`) | 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). When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | +| **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). | | **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. | | **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. | | **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. | @@ -493,7 +493,7 @@ Add a new field to an existing object | **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`. | | **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. | | **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`) | 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). When the object declares `searchableFields`, that list is the whole search set and this flag is not read (ADR-0061). | +| **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). | | **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. | | **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. | | **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. | diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 672512fe5da..7133d8b2356 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1415,7 +1415,9 @@ export const FieldSchema = lazySchema(() => { + '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). When the object declares `searchableFields`, that list ' + + '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).', ), /**