diff --git a/.changeset/field-default-value-discriminator.md b/.changeset/field-default-value-discriminator.md new file mode 100644 index 0000000000..f186b6ee1c --- /dev/null +++ b/.changeset/field-default-value-discriminator.md @@ -0,0 +1,63 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec): `FieldSchema.defaultValue` is discriminated (literal / runtime token / CEL envelope) and each shape is validated on its own terms (#7127) + +`FieldSchema.defaultValue` was `z.unknown().optional()` — the same acceptance +hole #6970 closed one layer up on action params, but NARROWER: a field default +is polymorphic by design (a literal, a runtime token `NOW()` / `current_user`, +or a CEL Expression envelope `{ dialect, source }`), so the vocabulary has to +be subtracted BEFORE the literal can be judged. Running the value contract +over the whole key judges a token's spelling as data — right only by accident +(`'current_user'` passes a `user` field as a would-be record id; `'NOW()'` +passes `text` as a plain string while the engine intercepts it and stores an +ISO instant instead). + +Per the maintainer's sequenced ruling (2026-08-10), this lands in two steps +inside one release: + +1. a shared **discriminator** (`@objectstack/spec/data`, + `default-value-shape.ts`): the engine's own envelope predicate verbatim, + the token predicates, and the shared literal-vs-stored-contract core — + one module, two consumers. The #6970 action-param gate is refactored onto + the shared core with zero behavior change. +2. `FieldSchema.defaultValue` narrowed on top of it, in the engine's own + discrimination order: + - **absent** (`null`/`undefined`) → skipped; `''` is a real default + (engine presence semantics, deliberately not the action-param rule); + - **CEL envelope** → structural acceptance only (the result type is + unknowable at parse time; a wrong one is an ADR-0032 runtime concern); + - **runtime token** → per-token × per-type: `NOW()` on + `datetime`/`date`/`time` (both resolvers and the docs already support + all three); `current_user` on `user` or `lookup` with + `reference: 'sys_user'` (#4560); no token on a multi-value field + (both resolve to one scalar); + - **literal** → the field's own stored value contract + (ADR-0104 D1 `valueSchemaFor(def, 'stored')`) — the #6970 mechanics one + layer down. + +Rejections are prescriptive: they name the field, its type, the offending +value verbatim, why it cannot hold, and the legal alternatives — including a +suggested token for predictable near-miss spellings (`'now'`, +`'{current_user}'`), which are suggested but never silently widened into +tokens (a genuinely-intended literal must stay storable). + +**Migration surface: zero.** All 371 shipped `defaultValue` declarations +across objectstack + cloud were re-censused against the implemented gate — +0 refusals (239 literals all pass their stored contracts; 131 × `NOW()` all +on `datetime`; 1 × `current_user` on `user`). The only declarations anywhere +that newly refuse are two hand-written docs samples that were already wrong +today (stored verbatim / dropped by the SQL DDL), fixed in this change. + +**Stock compatibility.** As with #6970: stored metadata carrying a +nonconforming default keeps loading (the read path runs no Zod validation); +authoritative spec validation lives on the WRITE path and surfaces on reads +as the advisory `_diagnostics` envelope. Loud at authoring, non-fatal at +rest, no conversion owed — there is no mechanical rewrite for "the author +meant something else". + +Also moved: `AddressSchema` is now declared in `field-value.zod.ts` (it IS +the enforced address value contract) and re-exported from `field.zod.ts` +unchanged — the move removes the one runtime ESM edge that would otherwise +have closed an evaluation cycle between the two modules. diff --git a/content/docs/references/data/field-value.mdx b/content/docs/references/data/field-value.mdx index ed8da5965b..aebc1644f3 100644 --- a/content/docs/references/data/field-value.mdx +++ b/content/docs/references/data/field-value.mdx @@ -40,13 +40,30 @@ this contract has (ADR-0104 performance budget). ## TypeScript Usage ```typescript -import { AddressValueSchema, CalendarDateValueSchema, ClockTimeValueSchema, FileLikeValueSchema, FileReferenceIdValueSchema, FileValueSchema, InstantValueSchema, LocationValueSchema, ReferenceIdValueSchema } from '@objectstack/spec/data'; -import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileReferenceIdValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data'; +import { AddressSchema, AddressValueSchema, CalendarDateValueSchema, ClockTimeValueSchema, FileLikeValueSchema, FileReferenceIdValueSchema, FileValueSchema, InstantValueSchema, LocationValueSchema, ReferenceIdValueSchema } from '@objectstack/spec/data'; +import type { Address, AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileReferenceIdValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data'; // Validate data -const result = AddressValueSchema.parse(data); +const result = AddressSchema.parse(data); ``` +--- + +## Address + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **street** | `string` | optional | Street address | +| **city** | `string` | optional | City name | +| **state** | `string` | optional | State/Province | +| **postalCode** | `string` | optional | Postal/ZIP code | +| **country** | `string` | optional | Country name or code | +| **countryCode** | `string` | optional | ISO country code (e.g., US, GB) | +| **formatted** | `string` | optional | Formatted address string | + + --- ## AddressValue diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 2af0fa0277..03d66f5dd3 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -14,30 +14,13 @@ Field Type Enum ## TypeScript Usage ```typescript -import { AddressSchema, CurrencyConfigSchema, CurrencyValueSchema, FieldSchema, FieldType, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data'; -import type { Address, CurrencyConfig, CurrencyValue, Field, FieldType, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data'; +import { CurrencyConfigSchema, CurrencyValueSchema, FieldSchema, FieldType, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data'; +import type { CurrencyConfig, CurrencyValue, Field, FieldType, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data'; // Validate data -const result = AddressSchema.parse(data); +const result = CurrencyConfigSchema.parse(data); ``` ---- - -## Address - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **street** | `string` | optional | Street address | -| **city** | `string` | optional | City name | -| **state** | `string` | optional | State/Province | -| **postalCode** | `string` | optional | Postal/ZIP code | -| **country** | `string` | optional | Country name or code | -| **countryCode** | `string` | optional | ISO country code (e.g., US, GB) | -| **formatted** | `string` | optional | Formatted address string | - - --- ## CurrencyConfig @@ -81,7 +64,7 @@ const result = AddressSchema.parse(data); | **searchable** | `boolean` | optional | Is searchable | | **multiple** | `boolean` | optional | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. | | **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' | -| **defaultValue** | `any` | optional | Default value | +| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `number` | optional | Max character length | | **minLength** | `number` | optional | Min character length | | **precision** | `number` | optional | Total digits | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 654c4a6674..1158acd90b 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -171,8 +171,8 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`external-catalog.zod.ts`](/docs/references/data/external-catalog) | `ExternalCatalog`, `ExternalColumn`, `ExternalTable` | | [`external-lookup.zod.ts`](/docs/references/data/external-lookup) | `ExternalDataSource`, `ExternalFieldMapping`, `ExternalLookup` | | [`feed.zod.ts`](/docs/references/data/feed) | `FeedFilterMode`, `FeedItemType` | -| [`field.zod.ts`](/docs/references/data/field) | `Address`, `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldType`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | -| [`field-value.zod.ts`](/docs/references/data/field-value) | `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` | +| [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldType`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | +| [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` | | [`filter.zod.ts`](/docs/references/data/filter) | `EqualityOperator`, `FieldReference`, `FilterArray`, `FilterCondition`, `QueryFilter`, `SetOperator`, `SpecialOperator`, `StringOperator` | | [`hook.zod.ts`](/docs/references/data/hook) | `HookContext`, `HookEvent` | | [`hook-body.zod.ts`](/docs/references/data/hook-body) | `ExpressionBody`, `HookBody`, `HookBodyCapability`, `ScriptBody` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 1ac17f5250..9a4eaf25e6 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -29,8 +29,8 @@ Remaining strip sites by class: | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 41 | -| unresolved — needs a per-schema verdict | 33 | +| authorable — the ruling's forced scope | 40 | +| unresolved — needs a per-schema verdict | 34 | | wire / open — out of forced scope | 105 | | no door — no carrier, ADR-0049 territory | 1 | | no gate — carrier live, no parse | 0 | @@ -98,8 +98,8 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `driver/turso.zod.ts` | 2 | | `external-catalog.zod.ts` | 4 | | `external-lookup.zod.ts` | 12 | -| `field-value.zod.ts` | 2 | -| `field.zod.ts` | 11 | +| `field-value.zod.ts` | 3 | +| `field.zod.ts` | 10 | | `filter.zod.ts` | 11 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 7 | @@ -192,8 +192,8 @@ over it is here. | `driver/memory.zod.ts` | 5 | 6 | | `external-catalog.zod.ts` | 4 | 4 | | `external-lookup.zod.ts` | 12 | 12 | -| `field-value.zod.ts` | 1 | 2 | -| `field.zod.ts` | 3 | 11 | +| `field-value.zod.ts` | 2 | 3 | +| `field.zod.ts` | 2 | 10 | | `filter.zod.ts` | 11 | 11 | | `hook.zod.ts` | 5 | 7 | | `object.zod.ts` | 1 | 20 | @@ -203,8 +203,8 @@ over it is here. | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 9 | -| unresolved — needs a per-schema verdict | 33 | +| authorable — the ruling's forced scope | 8 | +| unresolved — needs a per-schema verdict | 34 | | wire / open — out of forced scope | 66 | | no door — no carrier, ADR-0049 territory | 0 | | no gate — carrier live, no parse | 0 | diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index a957fe7805..c2a6e2cbb3 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -105,6 +105,7 @@ "DEFAULT_VALUE_TOKEN_CURRENT_USER (const)", "DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)", "DEFAULT_VALUE_TOKEN_NOW (const)", + "DEFAULT_VALUE_TOKEN_SUGGESTIONS (const)", "DRIVER_CONFIG_SCHEMAS (const)", "DRIVER_ID_ALIASES (const)", "DataEngineAggregateOptions (type)", @@ -155,6 +156,7 @@ "DateMacroToken (type)", "DateMacroTokenSchema (const)", "DateMacroUnit (type)", + "DefaultValueShape (type)", "DefaultValueToken (type)", "Dimension (type)", "DimensionSchema (const)", @@ -327,6 +329,7 @@ "LifecycleClass (type)", "LifecycleClassSchema (const)", "LifecycleSchema (const)", + "LiteralDefaultValueVerdict (interface)", "LocalStoragePersistenceConfig (type)", "LocalStoragePersistenceConfigSchema (const)", "LocationCoordinates (type)", @@ -357,6 +360,7 @@ "MysqlConfig (type)", "MysqlConfigParsed (type)", "MysqlConfigSchema (const)", + "NOW_DEFAULT_LEGAL_TYPES (const)", "NUMERIC_VALUE_TYPES (const)", "NoSQLDataTypeMapping (type)", "NoSQLDataTypeMappingSchema (const)", @@ -593,9 +597,11 @@ "asciiCaseInsensitiveRegexSource (function)", "canonicalAstOperator (function)", "canonicalizeSqlType (function)", + "checkLiteralDefaultValue (function)", "classifyFilterToken (function)", "countAuthorableFields (function)", "defaultAggregateFor (function)", + "defaultValueTokenIssue (function)", "defineCube (function)", "defineDatasource (function)", "defineHook (function)", @@ -605,6 +611,7 @@ "deriveFieldGroupLayout (function)", "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", + "discriminateDefaultValueShape (function)", "driverConfigJsonSchema (function)", "driverHasLocalDefault (function)", "effectiveOperationsArray (function)", @@ -631,6 +638,7 @@ "isContextToken (function)", "isCurrentUserDefaultToken (function)", "isDateMacroToken (function)", + "isExpressionEnvelopeDefault (function)", "isFileIdToken (function)", "isFilterAST (function)", "isGlobalUnique (function)", @@ -675,6 +683,7 @@ "resolveSearchFields (function)", "sequenceWidth (function)", "stripLegacyApiMethods (function)", + "suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType (function)", "utcInstantMs (function)", "validateDriverConfig (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index a5ae8463b5..72ff514e1c 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -11,7 +11,7 @@ "API_PRIMITIVES": "src/data/api-derivation.ts#API_PRIMITIVES (const)", "AUDIT_PROVENANCE_FIELDS": "src/data/field-group-layout.ts#AUDIT_PROVENANCE_FIELDS (const)", "Address": "src/data/field.zod.ts#Address (type)", - "AddressSchema": "src/data/field.zod.ts#AddressSchema (const)", + "AddressSchema": "src/data/field-value.zod.ts#AddressSchema (const)", "AddressValue": "src/data/field-value.zod.ts#AddressValue (type)", "AddressValueSchema": "src/data/field-value.zod.ts#AddressValueSchema (const)", "AggregationCase": "src/data/aggregation-conformance.ts#AggregationCase (interface)", @@ -105,6 +105,7 @@ "DEFAULT_VALUE_TOKEN_CURRENT_USER": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_CURRENT_USER (const)", "DEFAULT_VALUE_TOKEN_DESCRIPTIONS": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)", "DEFAULT_VALUE_TOKEN_NOW": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_NOW (const)", + "DEFAULT_VALUE_TOKEN_SUGGESTIONS": "src/data/default-value-shape.ts#DEFAULT_VALUE_TOKEN_SUGGESTIONS (const)", "DRIVER_CONFIG_SCHEMAS": "src/data/driver/config-registry.zod.ts#DRIVER_CONFIG_SCHEMAS (const)", "DRIVER_ID_ALIASES": "src/data/driver/config-registry.zod.ts#DRIVER_ID_ALIASES (const)", "DataEngineAggregateOptions": "src/data/data-engine.zod.ts#DataEngineAggregateOptions (type)", @@ -155,6 +156,7 @@ "DateMacroToken": "src/data/date-macros.zod.ts#DateMacroToken (type)", "DateMacroTokenSchema": "src/data/date-macros.zod.ts#DateMacroTokenSchema (const)", "DateMacroUnit": "src/data/date-macros.zod.ts#DateMacroUnit (type)", + "DefaultValueShape": "src/data/default-value-shape.ts#DefaultValueShape (type)", "DefaultValueToken": "src/data/default-value-tokens.ts#DefaultValueToken (type)", "Dimension": "src/data/analytics.zod.ts#Dimension (type)", "DimensionSchema": "src/data/analytics.zod.ts#DimensionSchema (const)", @@ -327,6 +329,7 @@ "LifecycleClass": "src/data/object.zod.ts#LifecycleClass (type)", "LifecycleClassSchema": "src/data/object.zod.ts#LifecycleClassSchema (const)", "LifecycleSchema": "src/data/object.zod.ts#LifecycleSchema (const)", + "LiteralDefaultValueVerdict": "src/data/default-value-shape.ts#LiteralDefaultValueVerdict (interface)", "LocalStoragePersistenceConfig": "src/data/driver/memory.zod.ts#LocalStoragePersistenceConfig (type)", "LocalStoragePersistenceConfigSchema": "src/data/driver/memory.zod.ts#LocalStoragePersistenceConfigSchema (const)", "LocationCoordinates": "src/data/field.zod.ts#LocationCoordinates (type)", @@ -357,6 +360,7 @@ "MysqlConfig": "src/data/driver/mysql.zod.ts#MysqlConfig (type)", "MysqlConfigParsed": "src/data/driver/mysql.zod.ts#MysqlConfigParsed (type)", "MysqlConfigSchema": "src/data/driver/mysql.zod.ts#MysqlConfigSchema (const)", + "NOW_DEFAULT_LEGAL_TYPES": "src/data/default-value-shape.ts#NOW_DEFAULT_LEGAL_TYPES (const)", "NUMERIC_VALUE_TYPES": "src/data/field-value.zod.ts#NUMERIC_VALUE_TYPES (const)", "NoSQLDataTypeMapping": "src/data/driver-nosql.zod.ts#NoSQLDataTypeMapping (type)", "NoSQLDataTypeMappingSchema": "src/data/driver-nosql.zod.ts#NoSQLDataTypeMappingSchema (const)", @@ -593,9 +597,11 @@ "asciiCaseInsensitiveRegexSource": "src/data/filter.zod.ts#asciiCaseInsensitiveRegexSource (function)", "canonicalAstOperator": "src/data/filter.zod.ts#canonicalAstOperator (function)", "canonicalizeSqlType": "src/data/type-compat.ts#canonicalizeSqlType (function)", + "checkLiteralDefaultValue": "src/data/default-value-shape.ts#checkLiteralDefaultValue (function)", "classifyFilterToken": "src/data/context-tokens.zod.ts#classifyFilterToken (function)", "countAuthorableFields": "src/data/record-surface.ts#countAuthorableFields (function)", "defaultAggregateFor": "src/data/aggregation-policy.ts#defaultAggregateFor (function)", + "defaultValueTokenIssue": "src/data/default-value-shape.ts#defaultValueTokenIssue (function)", "defineCube": "src/data/analytics.zod.ts#defineCube (function)", "defineDatasource": "src/data/datasource.zod.ts#defineDatasource (function)", "defineHook": "src/data/hook.zod.ts#defineHook (function)", @@ -605,6 +611,7 @@ "deriveFieldGroupLayout": "src/data/field-group-layout.ts#deriveFieldGroupLayout (function)", "deriveRecordFlowSurface": "src/data/record-surface.ts#deriveRecordFlowSurface (function)", "deriveRecordSurface": "src/data/record-surface.ts#deriveRecordSurface (function)", + "discriminateDefaultValueShape": "src/data/default-value-shape.ts#discriminateDefaultValueShape (function)", "driverConfigJsonSchema": "src/data/driver/common.zod.ts#driverConfigJsonSchema (function)", "driverHasLocalDefault": "src/data/driver/config-registry.zod.ts#driverHasLocalDefault (function)", "effectiveOperationsArray": "src/data/api-derivation.ts#effectiveOperationsArray (function)", @@ -631,6 +638,7 @@ "isContextToken": "src/data/context-tokens.zod.ts#isContextToken (function)", "isCurrentUserDefaultToken": "src/data/default-value-tokens.ts#isCurrentUserDefaultToken (function)", "isDateMacroToken": "src/data/date-macros.zod.ts#isDateMacroToken (function)", + "isExpressionEnvelopeDefault": "src/data/default-value-shape.ts#isExpressionEnvelopeDefault (function)", "isFileIdToken": "src/data/field-value.zod.ts#isFileIdToken (function)", "isFilterAST": "src/data/filter.zod.ts#isFilterAST (function)", "isGlobalUnique": "src/data/field.zod.ts#isGlobalUnique (function)", @@ -675,6 +683,7 @@ "resolveSearchFields": "src/data/search-fields.ts#resolveSearchFields (function)", "sequenceWidth": "src/data/autonumber-format.ts#sequenceWidth (function)", "stripLegacyApiMethods": "src/data/object.zod.ts#stripLegacyApiMethods (function)", + "suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)", "utcInstantMs": "src/data/calendar-day.ts#utcInstantMs (function)", "validateDriverConfig": "src/data/driver/config-registry.zod.ts#validateDriverConfig (function)", diff --git a/packages/spec/scripts/strictness-ledger.test.ts b/packages/spec/scripts/strictness-ledger.test.ts index f99ecd2068..3ea8acf9dc 100644 --- a/packages/spec/scripts/strictness-ledger.test.ts +++ b/packages/spec/scripts/strictness-ledger.test.ts @@ -123,8 +123,11 @@ describe('site counting reads the AST, not the source text', () => { }); it('knows every object idiom, including z.looseObject(', () => { + // 2 → 3 at #7127, which MOVED `AddressSchema` (a plain z.object site) in + // from `field.zod.ts`. As with the chart count above, the number is + // incidental — the assertion that carries the meaning is the idiom read. const fv = analyzeSites(at('data/field-value.zod.ts')); - expect(fv).toHaveLength(2); + expect(fv).toHaveLength(3); expect(fv.find((s) => s.name === 'FileValueSchema')?.idiom).toBe('z.looseObject'); }); }); diff --git a/packages/spec/src/data/default-value-shape.test.ts b/packages/spec/src/data/default-value-shape.test.ts new file mode 100644 index 0000000000..140ddfcea9 --- /dev/null +++ b/packages/spec/src/data/default-value-shape.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7127 — the `defaultValue` shape discriminator: literal vs runtime token vs + * Expression envelope, plus the shared literal-vs-value-contract check. + * + * The discrimination cases pin ENGINE PARITY: every verdict here is the one + * `ObjectQL.applyFieldDefaults` reaches for the same value (envelope by the + * same structural predicate, tokens by the same spec predicates, everything + * else the literal branch). A case that drifts from the engine is wrong HERE, + * not there — update the module only together with the resolver. + */ + +import { describe, it, expect } from 'vitest'; + +import { + checkLiteralDefaultValue, + discriminateDefaultValueShape, + isExpressionEnvelopeDefault, + type DefaultValueShape, +} from './default-value-shape'; + +describe('#7127 discriminateDefaultValueShape — engine-parity classification', () => { + const CASES: Array<{ label: string; dv: unknown; shape: DefaultValueShape }> = [ + // ── Expression envelopes: the engine's structural predicate, verbatim ──── + { label: 'canonical CEL envelope', dv: { dialect: 'cel', source: 'today()' }, shape: 'expression' }, + { + label: 'unknown dialect is STILL an envelope (evaluation failing is a runtime concern)', + dv: { dialect: 'made_up', source: 'x' }, + shape: 'expression', + }, + { + label: 'truthy non-string dialect counts (the engine tests truthiness, nothing more)', + dv: { dialect: 1, source: 'x' }, + shape: 'expression', + }, + { + label: 'missing `source` is NOT an envelope — the engine stores it verbatim', + dv: { dialect: 'cel' }, + shape: 'literal', + }, + { label: 'missing `dialect` is NOT an envelope', dv: { source: 'today()' }, shape: 'literal' }, + { label: 'falsy dialect is NOT an envelope', dv: { dialect: '', source: 'x' }, shape: 'literal' }, + { label: 'non-string source is NOT an envelope', dv: { dialect: 'cel', source: 7 }, shape: 'literal' }, + + // ── Runtime tokens: the spec predicates, spelling for spelling ─────────── + { label: 'NOW() exact', dv: 'NOW()', shape: 'token' }, + { label: 'NOW() is case-insensitive (now())', dv: 'now()', shape: 'token' }, + { label: 'NOW() is whitespace-tolerant ( NOW() )', dv: ' NOW() ', shape: 'token' }, + { label: 'current_user exact', dv: 'current_user', shape: 'token' }, + + // ── Near-misses are LITERALS — never silently widened into tokens ──────── + { label: 'CURRENT_USER (wrong case) is a literal', dv: 'CURRENT_USER', shape: 'literal' }, + { label: 'currentUser (camelCase) is a literal', dv: 'currentUser', shape: 'literal' }, + { label: '{current_user} (filter-vocabulary braces) is a literal', dv: '{current_user}', shape: 'literal' }, + { label: 'NOW (no parens) is a literal', dv: 'NOW', shape: 'literal' }, + + // ── Ordinary literals ──────────────────────────────────────────────────── + { label: 'string literal', dv: 'open', shape: 'literal' }, + { label: 'number literal', dv: 0, shape: 'literal' }, + { label: 'boolean literal', dv: false, shape: 'literal' }, + { label: 'array literal', dv: ['a'], shape: 'literal' }, + { label: 'plain object literal', dv: { a: 1 }, shape: 'literal' }, + ]; + + for (const { label, dv, shape } of CASES) { + it(`${shape}: ${label}`, () => { + expect(discriminateDefaultValueShape(dv)).toBe(shape); + }); + } + + it('absence is the CALLER\'s question — null/undefined fall out as literal here', () => { + // Consumers apply their own presence predicate BEFORE discriminating (the + // engine and the action-param dispatcher disagree about `''`, and both are + // right for their surface). Handing absence in anyway must not crash. + expect(discriminateDefaultValueShape(null)).toBe('literal'); + expect(discriminateDefaultValueShape(undefined)).toBe('literal'); + }); + + it('isExpressionEnvelopeDefault is the exported predicate the classification runs on', () => { + expect(isExpressionEnvelopeDefault({ dialect: 'cel', source: 'today()' })).toBe(true); + expect(isExpressionEnvelopeDefault({ dialect: 'cel' })).toBe(false); + expect(isExpressionEnvelopeDefault('NOW()')).toBe(false); + expect(isExpressionEnvelopeDefault(null)).toBe(false); + }); +}); + +describe('#7127 checkLiteralDefaultValue — the shared stored-form literal check', () => { + it('refuses a literal that cannot satisfy the stored contract, with the contract\'s own detail', () => { + const v = checkLiteralDefaultValue({ type: 'number' }, 'abc'); + expect(v.ok).toBe(false); + expect(v.detail).toBeTruthy(); + + const wallClock = checkLiteralDefaultValue({ type: 'datetime' }, '2026-08-10T15:00'); + expect(wallClock.ok).toBe(false); + // The detail is the value contract's message VERBATIM — the same words the + // #6970 action-param gate and the submit-time dispatcher carry. + expect(wallClock.detail).toContain('ISO-8601 instant'); + }); + + it('accepts a literal the stored contract accepts', () => { + expect(checkLiteralDefaultValue({ type: 'number' }, 7).ok).toBe(true); + expect(checkLiteralDefaultValue({ type: 'boolean' }, false).ok).toBe(true); + expect(checkLiteralDefaultValue({ type: 'date' }, '2026-08-10').ok).toBe(true); + }); + + it('judges option membership from the def, exactly as valueSchemaFor does', () => { + const def = { type: 'select', options: [{ value: 'gold' }, { value: 'silver' }] }; + expect(checkLiteralDefaultValue(def, 'gold').ok).toBe(true); + expect(checkLiteralDefaultValue(def, 'platinum').ok).toBe(false); + }); + + it('judges arity from the def (`multiple: true` stores an array)', () => { + expect(checkLiteralDefaultValue({ type: 'user', multiple: true }, 'usr_1').ok).toBe(false); + expect(checkLiteralDefaultValue({ type: 'user', multiple: true }, ['usr_1']).ok).toBe(true); + }); + + it('stays open where the contract is deliberately open (json)', () => { + expect(checkLiteralDefaultValue({ type: 'json' }, { anything: ['at', 'all'] }).ok).toBe(true); + }); +}); diff --git a/packages/spec/src/data/default-value-shape.ts b/packages/spec/src/data/default-value-shape.ts new file mode 100644 index 0000000000..1fbd0e869b --- /dev/null +++ b/packages/spec/src/data/default-value-shape.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `defaultValue` shape discrimination — the ONE place that tells the three + * legal shapes of an authored default apart (#7127; maintainer ruling + * 2026-08-10: one discriminator, two consumers). + * + * An authored `defaultValue` is polymorphic by design. Three shapes are legal: + * + * 1. a **literal** — stored verbatim (`'open'`, `0`, `false`); + * 2. a **runtime token** — `./default-value-tokens`' vocabulary (`'NOW()'`, + * `'current_user'`): an instruction resolved at insert time, never a + * value to store; + * 3. an **Expression envelope** — `{ dialect: 'cel', source: 'today()' }` + * (ROADMAP §M9.9b), evaluated by `ExpressionEngine` at insert time. + * + * Any gate that judges a default against its owner's VALUE contract + * (ADR-0104 D1, `valueSchemaFor`) must subtract shapes 2 and 3 FIRST. + * Running the value contract over the whole key judges a token's SPELLING as + * data, which is right only by accident: `'current_user'` passes a `user` + * field as a would-be record id (nothing recognised it as a token), and + * `'NOW()'` passes `text` as a plain string while the engine intercepts it + * before the literal branch and stores an ISO instant instead. The + * subtraction is therefore structural, runs first, and lives HERE — one + * module the authoring gates and the engine's resolution order cannot drift + * away from separately. + * + * # Consumers, and what each does with the verdict + * + * - **`FieldSchema`** (`./field.zod`, #7127): full discrimination — envelope + * accepted structurally, token gated per-token × per-type, literal checked + * through {@link checkLiteralDefaultValue}. + * - **`ActionParamSchema`** (`../ui/action.zod`, #6970): NO discrimination, + * by design. An action param's default is a pure LITERAL — objectui's + * `ActionParamDialog` seeds dialog state with it verbatim and + * `serializeParamValues` resolves nothing — so a token spelling there is + * judged as the literal it would be at submit. That consumer calls + * {@link checkLiteralDefaultValue} directly and deliberately skips + * {@link discriminateDefaultValueShape}. + * + * # Presence is the CONSUMER's question — deliberately not answered here + * + * The two consumers disagree about absence, and both are right for their + * surface: the engine treats `defaultValue == null` as absent and `''` as a + * real default (`ObjectQL.applyFieldDefaults`, insert-only), while an action + * param treats `''` as absent because the dispatcher's own presence predicate + * does (`isActionParamValuePresent`). A shared presence rule would be wrong + * for one of them, so each consumer applies its own BEFORE discriminating. + */ + +import { + DEFAULT_VALUE_TOKEN_CURRENT_USER, + DEFAULT_VALUE_TOKEN_NOW, + type DefaultValueToken, + isNowDefaultToken, + isRuntimeDefaultToken, +} from './default-value-tokens'; +import { isMultiValueField, valueSchemaFor, type ValueShapeFieldDef } from './field-value.zod'; +import { SystemObjectName } from '../system/constants/system-names'; + +/** The three legal shapes of a PRESENT `defaultValue`. */ +export type DefaultValueShape = 'expression' | 'token' | 'literal'; + +/** + * Is `dv` an Expression envelope, structurally? — an object with a truthy + * `dialect` and a string `source`. + * + * This predicate is the ENGINE's, verbatim (`ObjectQL.applyFieldDefaults`): + * recognition is by SHAPE, not by schema. A well-formed envelope with an + * unknown dialect is still an envelope (its evaluation failing is an ADR-0032 + * runtime concern, surfaced as a `logger.warn`), and an object missing + * `source` is NOT one — the engine falls through and stores it verbatim as a + * literal. Matching the resolver's reachability exactly is the point: what + * the engine treats as an instruction, an authoring gate must too, or the two + * answer differently for the same declaration. + */ +export function isExpressionEnvelopeDefault(dv: unknown): boolean { + return ( + typeof dv === 'object' + && dv !== null + && Boolean((dv as { dialect?: unknown }).dialect) + && typeof (dv as { source?: unknown }).source === 'string' + ); +} + +/** + * Which of the three legal shapes is this (present) default? + * + * Discrimination order matches the engine's resolution order: envelope → + * token → literal. Absence is the caller's question (see the module note); + * a `null`/`undefined` handed in anyway falls out as `'literal'`. + */ +export function discriminateDefaultValueShape(dv: unknown): DefaultValueShape { + if (isExpressionEnvelopeDefault(dv)) return 'expression'; + if (isRuntimeDefaultToken(dv)) return 'token'; + return 'literal'; +} + +/** Verdict of {@link checkLiteralDefaultValue}: `ok`, or the first contract violation. */ +export interface LiteralDefaultValueVerdict { + ok: boolean; + /** First issue message from the value contract — the "why" a refusal carries verbatim. */ + detail?: string; +} + +/** + * Check a LITERAL default against its owner's own stored-form value contract + * (`valueSchemaFor(def, 'stored')` — ADR-0104 D1). The shared core of the + * #6970 action-param gate and the #7127 field gate: one rule set, one form + * (`'stored'`), so the two authoring surfaces cannot drift into two dialects + * of "what may this default hold". + * + * Callers discriminate (or deliberately decline to — see the module note) + * BEFORE calling this: a runtime token or an Expression envelope is not a + * literal and must never reach the value contract. + */ +export function checkLiteralDefaultValue(def: ValueShapeFieldDef, dv: unknown): LiteralDefaultValueVerdict { + const result = valueSchemaFor(def, 'stored').safeParse(dv); + if (result.success) return { ok: true }; + return { ok: false, detail: result.error.issues[0]?.message ?? 'invalid value' }; +} + +/* ──────────────────────────────────────────────────────────────────────────── + * Per-token × per-type gating (#7127 — the FieldSchema consumer's table) + * ──────────────────────────────────────────────────────────────────────────── */ + +/** + * Field types `'NOW()'` may legally default. + * + * Exactly the three types BOTH resolvers already branch on: the engine's + * `resolveNowDefault` produces the per-type stored form for `date` / `time` / + * instant, and the SQL driver's `nowColumnDefault` emits a dialect-correct + * physical DEFAULT for the same three. The docs promise the same set + * (`content/docs/protocol/objectql/types.mdx`: "for `date`, `datetime`, and + * `time` alike"). This codifies what the platform DOES — not narrowed to what + * happens to be authored today (the shipped census is 131 × `datetime`, 0 × + * `date`/`time`; an artifact of authorship, not of support). + */ +export const NOW_DEFAULT_LEGAL_TYPES: ReadonlySet = new Set(['datetime', 'date', 'time']); + +/** + * The refusal for a runtime-token default on a field it cannot legally + * default, or `null` when the token is legal there. Returns message TEXT so + * the consuming schema owns issue assembly; the text follows the + * self-prescribing house style — it names the field, its type, the offending + * token verbatim, why it cannot hold, and the legal alternatives. + * + * The table (#7127, decided on the issue): + * + * | token | legal on | why | + * |---|---|---| + * | `NOW()` | `datetime`, `date`, `time` | {@link NOW_DEFAULT_LEGAL_TYPES} | + * | `current_user` | `user`; `lookup` with `reference: 'sys_user'` | the engine writes `String(execCtx.userId)` — a `sys_user.id`; #4560 records that id landing anywhere else | + * + * Both tokens resolve to a single SCALAR (`applyFieldDefaults` assigns one + * string; a physical column DEFAULT is one value), so a multi-value field — + * an inherently-multi type, or a multi-capable type flagged `multiple: true` + * — refuses EVERY token before the per-token rules are consulted. + */ +export function defaultValueTokenIssue( + def: ValueShapeFieldDef & { name?: string; reference?: string }, + dv: unknown, +): string | null { + if (!isRuntimeDefaultToken(dv)) return null; + const token: DefaultValueToken = isNowDefaultToken(dv) + ? DEFAULT_VALUE_TOKEN_NOW + : DEFAULT_VALUE_TOKEN_CURRENT_USER; + const name = def.name ?? ''; + + if (isMultiValueField(def)) { + return ( + `Field "${name}" (${def.type}, multi-value): the default ${JSON.stringify(dv)} is the runtime token ` + + `\`${token}\`, which resolves to a single scalar — the engine's insert-time resolution assigns one ` + + 'value (applyFieldDefaults) and a physical column DEFAULT is one value; neither produces the array a ' + + 'multi-value field stores. Make the field single-valued, or write a literal array default.' + ); + } + + if (token === DEFAULT_VALUE_TOKEN_NOW) { + if (NOW_DEFAULT_LEGAL_TYPES.has(def.type)) return null; + return ( + `Field "${name}" (${def.type}): the default ${JSON.stringify(dv)} is the runtime token \`NOW()\` — ` + + 'the insert-time clock, resolved by the engine and by the SQL column DEFAULT into a temporal stored ' + + `form — and a \`${def.type}\` field has no such form. It is legal only on \`datetime\`, \`date\`, or ` + + '`time`. Use one of those temporal types, or write a literal value this field can store. (Before this ' + + 'gate the engine silently intercepted the token even here and stored an ISO instant — an interception ' + + 'nobody chose; it is refused instead.)' + ); + } + + // current_user — legal on `user`, and on a `lookup` targeting the user table. + if (def.type === 'user') return null; + if (def.type === 'lookup' && def.reference === SystemObjectName.USER) return null; + const lookupAside = def.type === 'lookup' + ? ` (this lookup targets ${def.reference ? `\`${def.reference}\`` : 'no declared object'}, not \`sys_user\`)` + : ''; + return ( + `Field "${name}" (${def.type}): the default ${JSON.stringify(dv)} is the runtime token \`current_user\` — ` + + `resolved by the engine to the acting user's \`sys_user.id\` at insert time — and a \`${def.type}\` field ` + + `cannot hold one${lookupAside}. It is legal only on a \`user\` field or a \`lookup\` with ` + + "`reference: 'sys_user'` (#4560 records what happens when that id lands anywhere else). Use one of " + + 'those, or write a literal record id.' + ); +} + +/* ──────────────────────────────────────────────────────────────────────────── + * Near-miss token spellings (#7127 Q5) — suggested, never accepted + * ──────────────────────────────────────────────────────────────────────────── */ + +/** + * Predictable near-miss spellings of the runtime tokens, mapped to the token + * the author probably meant — the `CONTEXT_TOKEN_SUGGESTIONS` pattern + * (`./context-tokens.zod.ts`) applied to this vocabulary. Keyed on the + * trimmed, lowercased spelling. The braced forms are the documented overlap + * with the FILTER placeholder vocabulary (`{current_user_id}`), which does + * not resolve in a `defaultValue`. + * + * Suggestions surface ONLY inside a refusal already happening on the literal + * branch. They are deliberately never accepted as tokens, and a near-miss + * that is a VALID literal for its field stays accepted in silence: widening + * the token match would make a genuinely-intended literal unstorable + * (`./default-value-tokens.ts` records that refusal; lint owns the catch). + */ +export const DEFAULT_VALUE_TOKEN_SUGGESTIONS: Readonly> = { + currentuser: DEFAULT_VALUE_TOKEN_CURRENT_USER, + 'current-user': DEFAULT_VALUE_TOKEN_CURRENT_USER, + 'current user': DEFAULT_VALUE_TOKEN_CURRENT_USER, + '{current_user}': DEFAULT_VALUE_TOKEN_CURRENT_USER, + current_user_id: DEFAULT_VALUE_TOKEN_CURRENT_USER, + '{current_user_id}': DEFAULT_VALUE_TOKEN_CURRENT_USER, + now: DEFAULT_VALUE_TOKEN_NOW, + '{now}': DEFAULT_VALUE_TOKEN_NOW, + current_datetime: DEFAULT_VALUE_TOKEN_NOW, + current_time: DEFAULT_VALUE_TOKEN_NOW, +} as const; + +/** + * The runtime token a refused literal was probably reaching for, or + * `undefined` when it resembles none. Exact token spellings never land here: + * discrimination sends them down the token branch first, and this function + * additionally refuses to answer for them so a direct caller cannot turn a + * token into a "suggestion" of itself. + */ +export function suggestDefaultValueToken(dv: unknown): DefaultValueToken | undefined { + if (typeof dv !== 'string') return undefined; + // Key first: `isRuntimeDefaultToken` is a `v is string` guard, so its + // negation narrows a `string` operand to `never` — dereferencing after the + // guard is a type error, not just awkward. + const key = dv.trim().toLowerCase(); + if (isRuntimeDefaultToken(dv)) return undefined; + return DEFAULT_VALUE_TOKEN_SUGGESTIONS[key]; +} diff --git a/packages/spec/src/data/field-default-value.test.ts b/packages/spec/src/data/field-default-value.test.ts new file mode 100644 index 0000000000..25178d7c86 --- /dev/null +++ b/packages/spec/src/data/field-default-value.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7127 — `FieldSchema.defaultValue` must be one of the key's three legal + * shapes (CEL envelope / runtime token / literal), and legal for ITS OWN + * field: envelope accepted structurally, token gated per-token × per-type, + * literal checked through the same `valueSchemaFor(def, 'stored')` the #6970 + * action-param gate runs (ADR-0104 D1 — one rule set, two surfaces). + * + * Before this, `defaultValue` was a bare `z.unknown()`: a default that could + * never satisfy its own field parsed clean and surfaced as bad data or a + * runtime failure far from the cause. + * + * Every rejection pin asserts issue PATH + CODE + MESSAGE CONTENT — never a + * bare `success === false`, which cannot tell this rejection from the schema + * refusing the field for an unrelated reason. + */ + +import { describe, it, expect } from 'vitest'; + +import { FieldSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; + +/** Parse a field and return its first `defaultValue` issue, or `null`. */ +function defaultValueIssue(field: Record) { + const r = FieldSchema.safeParse({ name: 'probe_field', label: 'Probe', ...field }); + if (r.success) return null; + return r.error.issues.find((i) => i.path.join('.') === 'defaultValue') ?? null; +} + +type Case = { + label: string; + field: Record; + accepted: boolean; + /** Substrings the refusal message must carry (rejection rows only). */ + contains?: string[]; +}; + +const CASES: Case[] = [ + // ── Literal branch: the residual hole the issue measured, now refused ───── + { + label: "number + literal 'abc'", + field: { type: 'number', defaultValue: 'abc' }, + accepted: false, + contains: ['"probe_field"', '(number)', '"abc"', 'stored value contract'], + }, + { + label: 'datetime + wall clock (no zone)', + field: { type: 'datetime', defaultValue: '2026-08-10T15:00' }, + accepted: false, + contains: ['ISO-8601 instant'], + }, + { + label: 'select + a non-member of its own options', + field: { + type: 'select', + options: [{ label: 'Gold', value: 'gold' }, { label: 'Silver', value: 'silver' }], + defaultValue: 'platinum', + }, + accepted: false, + contains: ['"platinum"'], + }, + { label: 'boolean + 42', field: { type: 'boolean', defaultValue: 42 }, accepted: false, contains: ['42'] }, + { + label: 'number + arbitrary object', + field: { type: 'number', defaultValue: { a: 1 } }, + accepted: false, + }, + { + label: 'multi-value user + scalar literal (arity is part of the stored contract)', + field: { type: 'user', multiple: true, defaultValue: 'usr_1' }, + accepted: false, + }, + + // ── Literal branch: valid literals stay accepted ────────────────────────── + { label: 'VALID number', field: { type: 'number', defaultValue: 7 }, accepted: true }, + { label: 'VALID boolean', field: { type: 'boolean', defaultValue: false }, accepted: true }, + { label: 'VALID date (calendar day)', field: { type: 'date', defaultValue: '2026-08-10' }, accepted: true }, + { + label: 'VALID select member', + field: { type: 'select', options: [{ label: 'Gold', value: 'gold' }], defaultValue: 'gold' }, + accepted: true, + }, + { + label: 'VALID multi-value user array', + field: { type: 'user', multiple: true, defaultValue: ['usr_1'] }, + accepted: true, + }, + { + label: 'json — explicitly OPEN contract, any literal rides', + field: { type: 'json', defaultValue: { anything: ['at', 'all'] } }, + accepted: true, + }, + { + label: "'' is a PRESENT default (engine semantics) and a valid text literal", + field: { type: 'text', defaultValue: '' }, + accepted: true, + }, + + // ── Token branch: NOW() per-type (Q1 / Q2) ──────────────────────────────── + { label: 'NOW() on datetime', field: { type: 'datetime', defaultValue: 'NOW()' }, accepted: true }, + { label: 'NOW() on date (supported end-to-end, Q1 in)', field: { type: 'date', defaultValue: 'NOW()' }, accepted: true }, + { label: 'NOW() on time (supported end-to-end, Q1 in)', field: { type: 'time', defaultValue: 'NOW()' }, accepted: true }, + { + label: 'NOW() on text — refused; the silent instant-into-text interception nobody chose (Q2)', + field: { type: 'text', defaultValue: 'NOW()' }, + accepted: false, + contains: ['"probe_field"', '(text)', '"NOW()"', '`datetime`', '`date`', '`time`'], + }, + { + label: 'now() lowercase is the SAME token (case-insensitive), same refusal on number', + field: { type: 'number', defaultValue: 'now()' }, + accepted: false, + contains: ['NOW()'], + }, + { + label: 'NOW() on json — the token branch applies even where the literal contract is open', + field: { type: 'json', defaultValue: 'NOW()' }, + accepted: false, + }, + + // ── Token branch: current_user per-type (Q4 strict) ─────────────────────── + { label: 'current_user on user', field: { type: 'user', defaultValue: 'current_user' }, accepted: true }, + { + label: "current_user on lookup(reference: 'sys_user')", + field: { type: 'lookup', reference: 'sys_user', defaultValue: 'current_user' }, + accepted: true, + }, + { + label: 'current_user on number', + field: { type: 'number', defaultValue: 'current_user' }, + accepted: false, + contains: ['"current_user"', 'sys_user.id'], + }, + { + label: 'current_user on lookup targeting another object', + field: { type: 'lookup', reference: 'accounts', defaultValue: 'current_user' }, + accepted: false, + contains: ['`accounts`', "reference: 'sys_user'"], + }, + { + label: 'current_user on lookup with NO reference (strict: the gate reads the target)', + field: { type: 'lookup', defaultValue: 'current_user' }, + accepted: false, + contains: ['no declared object'], + }, + { + label: 'current_user on text — passes valueSchemaFor as a string, but the token branch runs FIRST', + field: { type: 'text', defaultValue: 'current_user' }, + accepted: false, + }, + + // ── Token branch: multi-value refusal (Q3) ──────────────────────────────── + { + label: 'current_user on user + multiple:true — a token resolves to one scalar', + field: { type: 'user', multiple: true, defaultValue: 'current_user' }, + accepted: false, + contains: ['multi-value', 'single scalar'], + }, + { + label: 'NOW() on multiselect — inherently multi, same refusal', + field: { type: 'multiselect', options: [{ label: 'A', value: 'a' }], defaultValue: 'NOW()' }, + accepted: false, + contains: ['multi-value'], + }, + + // ── Envelope branch: structural acceptance only ─────────────────────────── + { + label: 'CEL envelope on date', + field: { type: 'date', defaultValue: { dialect: 'cel', source: 'today()' } }, + accepted: true, + }, + { + label: 'envelope with an unknown dialect is STILL an envelope (runtime concern, not parse)', + field: { type: 'number', defaultValue: { dialect: 'made_up', source: 'x' } }, + accepted: true, + }, + { + label: 'envelope MISSING `source` is not an envelope — judged (and refused) as an object literal', + field: { type: 'datetime', defaultValue: { dialect: 'cel' } }, + accepted: false, + }, + + // ── Near-misses (Q5): suggested inside refusals, never accepted, never widened ── + { + label: "datetime + 'now' — refused as a literal, with the token suggested", + field: { type: 'datetime', defaultValue: 'now' }, + accepted: false, + contains: ['Did you mean the runtime token `NOW()`', 'never accepted as tokens'], + }, + { + label: "user + '{current_user}' (filter-vocabulary braces) — refused, with the token suggested", + field: { type: 'user', defaultValue: '{current_user}' }, + accepted: false, + contains: ['Did you mean the runtime token `current_user`'], + }, + { + label: "text + 'CURRENT_USER' — a VALID string literal; near-misses are never widened into refusals", + field: { type: 'text', defaultValue: 'CURRENT_USER' }, + accepted: true, + }, +]; + +describe('#7127 FieldSchema.defaultValue — three shapes, each judged on its own terms', () => { + for (const { label, field, accepted, contains } of CASES) { + it(`${accepted ? 'accepts' : 'rejects'}: ${label}`, () => { + const issue = defaultValueIssue(field); + if (accepted) { + expect(issue).toBeNull(); + return; + } + expect(issue).not.toBeNull(); + expect(issue!.path).toEqual(['defaultValue']); + expect(issue!.code).toBe('custom'); + // Self-prescribing house style: the message names the field and carries + // the case-specific prescription. + expect(issue!.message).toContain('"probe_field"'); + for (const fragment of contains ?? []) { + expect(issue!.message).toContain(fragment); + } + }); + } + + it('skips ABSENT defaults — null and undefined mean "no default" (engine semantics)', () => { + expect(defaultValueIssue({ type: 'number', defaultValue: null })).toBeNull(); + expect(defaultValueIssue({ type: 'number' })).toBeNull(); + }); + + it('reports through the real authoring door with a full field path', () => { + const r = ObjectSchema.safeParse({ + name: 'probe_object', + label: 'Probe', + fields: { + due_at: { type: 'datetime', label: 'Due', defaultValue: '2026-08-10T15:00' }, + }, + }); + expect(r.success).toBe(false); + const paths = r.error!.issues.map((i) => i.path.join('.')); + expect(paths).toContain('fields.due_at.defaultValue'); + }); + + it('carries the value contract\'s own words on the literal branch — the same detail the action-param gate carries', () => { + const issue = defaultValueIssue({ type: 'datetime', defaultValue: '2026-08-10T15:00' })!; + expect(issue.message).toContain('expected an ISO-8601 instant with explicit zone'); + }); + + it('does not disturb the ADR-0113 refinement sharing the same block — both issues surface together', () => { + const r = FieldSchema.safeParse({ + name: 'probe_field', + label: 'Probe', + type: 'number', + requiredWhen: 'record.other == 1', + storage: { notNull: true }, + defaultValue: 'abc', + }); + expect(r.success).toBe(false); + const paths = r.error!.issues.map((i) => i.path.join('.')); + expect(paths).toContain('storage.notNull'); + expect(paths).toContain('defaultValue'); + }); +}); diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index be91afa578..5a00d209a8 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -34,7 +34,6 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { SystemObjectName } from '../system/constants/system-names'; import type { FieldType } from './field.zod'; -import { AddressSchema } from './field.zod'; /* ──────────────────────────────────────────────────────────────────────────── * Semantic type classes @@ -257,6 +256,28 @@ export const LocationValueSchema = lazySchema(() => z.object({ })); export type LocationValue = z.input; +/** + * Address Schema — structured address for the `address` field type. + * + * DECLARED here since #7127 (previously in `./field.zod`, which re-exports it + * for compatibility): it is the enforced address VALUE contract, so this + * module is its true home — and the old `field.zod` declaration was the ONE + * runtime edge back into that file. `field.zod` now consumes this module's + * value contract for its `defaultValue` gate, and a runtime edge in each + * direction is an ESM evaluation cycle whose order-dependent TDZ crash this + * move retires structurally (the remaining `FieldType` import above is + * type-only and erased at runtime). + */ +export const AddressSchema = lazySchema(() => z.object({ + street: z.string().optional().describe('Street address'), + city: z.string().optional().describe('City name'), + state: z.string().optional().describe('State/Province'), + postalCode: z.string().optional().describe('Postal/ZIP code'), + country: z.string().optional().describe('Country name or code'), + countryCode: z.string().optional().describe('ISO country code (e.g., US, GB)'), + formatted: z.string().optional().describe('Formatted address string'), +})); + /** Structured address value — adopts the (previously unconsumed) `AddressSchema` as the enforced contract. */ export const AddressValueSchema = AddressSchema; export type AddressValue = z.input; diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 3dc62a38ad..91aee8aa35 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -9,6 +9,19 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; import { FilterConditionSchema } from './filter.zod'; import { FIELD_KEY_GUIDANCE } from './authoring-key-lint'; import { DEFAULT_AUTONUMBER_FORMAT } from './autonumber-format'; +// #7127 — the `defaultValue` authoring gate: shape discrimination (literal / +// runtime token / CEL envelope), the per-token × per-type table, and the +// shared literal-vs-stored-contract check. `default-value-shape` reaches +// `field-value.zod`, whose only import back into THIS file is the type-only +// `FieldType` (erased at runtime) — `AddressSchema` moved there (see its +// re-export below), so the edge is one-way and no runtime ESM cycle closes. +import { + checkLiteralDefaultValue, + defaultValueTokenIssue, + discriminateDefaultValueShape, + suggestDefaultValueToken, +} from './default-value-shape'; +import { AddressSchema } from './field-value.zod'; /** * Field Type Enum @@ -220,18 +233,17 @@ export const CurrencyValueSchema = lazySchema(() => z.object({ })); /** - * Address Schema - * Structured address for address field type + * Address Schema — structured address for the `address` field type. + * + * DECLARED in `./field-value.zod` since #7127 (it IS the enforced address + * VALUE contract, ADR-0104 D1) and re-exported here for compatibility. The + * move is what lets THIS file import the value-contract module for its + * `defaultValue` gate without closing a runtime ESM cycle: `field-value.zod` + * dereferenced `AddressSchema` at module-eval time, and that top-level read + * was the one runtime edge back into this file (its remaining `FieldType` + * import is type-only, erased at runtime). */ -export const AddressSchema = lazySchema(() => z.object({ - street: z.string().optional().describe('Street address'), - city: z.string().optional().describe('City name'), - state: z.string().optional().describe('State/Province'), - postalCode: z.string().optional().describe('Postal/ZIP code'), - country: z.string().optional().describe('Country name or code'), - countryCode: z.string().optional().describe('ISO country code (e.g., US, GB)'), - formatted: z.string().optional().describe('Formatted address string'), -})); +export { AddressSchema }; /** * Field Schema - Best Practice Enterprise Pattern @@ -511,7 +523,7 @@ export const FieldSchema = lazySchema(() => strictObject({ // `(tenantField, field)` index); `'global'` = platform-wide single-column // unique. See {@link UniqueScopeSchema} for the scope vocabulary (ADR-0120). unique: UniqueScopeSchema.default(false).describe("Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'"), - defaultValue: z.unknown().optional().describe('Default value'), + defaultValue: z.unknown().optional().describe('Default applied on INSERT when the field is omitted or null (`\'\'` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine\'s own order: a CEL Expression envelope `{ dialect: \'cel\', source: \'today()\' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: \'sys_user\'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field\'s own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message.'), /** Text/String Constraints */ maxLength: z.number().optional().describe('Max character length'), @@ -933,6 +945,60 @@ export const FieldSchema = lazySchema(() => strictObject({ '`requiredWhen` alone for a conditional write contract (the column stays nullable).', }); } + + // #7127: an authored `defaultValue` must be one of the key's three legal + // shapes — CEL envelope / runtime token / literal — and legal for THIS + // field. The shapes are told apart FIRST (`default-value-shape.ts`, the + // engine's own discrimination order): running the value contract over the + // whole key would judge a token's spelling as data, which is right only by + // accident. Then each branch gets its own verdict: + // envelope → structural acceptance ONLY (a CEL result type is unknowable + // at parse time; a wrong one is an ADR-0032 runtime concern); + // token → the per-token × per-type table (`defaultValueTokenIssue`); + // literal → the field's own stored value contract, through the SAME + // shared core the #6970 action-param gate runs. + // + // Presence is the ENGINE's rule (`applyFieldDefaults`): `null`/`undefined` + // mean "no default", while `''` is a real default — deliberately NOT the + // action-param rule, whose dispatcher treats blank as absent. + const dv = field.defaultValue; + if (dv == null) return; + const dvText = JSON.stringify(dv) ?? String(dv); + const shape = discriminateDefaultValueShape(dv); + if (shape === 'expression') return; + if (shape === 'token') { + const message = defaultValueTokenIssue( + { type: field.type, multiple: field.multiple, options: field.options, name: field.name, reference: field.reference }, + dv, + ); + if (message !== null) { + ctx.addIssue({ code: 'custom', path: ['defaultValue'], message }); + } + return; + } + const verdict = checkLiteralDefaultValue( + { type: field.type, multiple: field.multiple, options: field.options }, + dv, + ); + if (!verdict.ok) { + const suggestion = suggestDefaultValueToken(dv); + const suggestionText = suggestion === undefined + ? '' + : ` Did you mean the runtime token \`${suggestion}\`? Near-miss spellings are never accepted as tokens ` + + '(a genuinely-intended literal must stay storable) — write the exact token, or a valid literal.'; + ctx.addIssue({ + code: 'custom', + path: ['defaultValue'], + message: + `Field "${field.name ?? ''}" (${field.type}): the default ${dvText} cannot satisfy this ` + + `field's own stored value contract — ${verdict.detail ?? 'invalid value'}. The engine stores a ` + + 'literal default VERBATIM at insert (applyFieldDefaults), so this would seed data the field type ' + + "cannot hold, surfacing far from the cause. Write the default in the field's stored value shape, " + + 'or use one of the other legal shapes: a runtime token (`NOW()` on `datetime`/`date`/`time`; ' + + "`current_user` on `user` or `lookup` with `reference: 'sys_user'`) or a CEL Expression envelope " + + `({ dialect: 'cel', source: '…' }).${suggestionText}`, + }); + } })); /** diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 1c7c9f7a9c..d3ad441c31 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -45,6 +45,10 @@ export * from './context-tokens.zod'; // insert-time default resolution and every driver's DDL agree on which // `defaultValue`s may become a physical column DEFAULT (#4560). export * from './default-value-tokens'; +// The shape discriminator over that vocabulary (#7127): literal vs runtime +// token vs Expression envelope, plus the shared literal-vs-value-contract +// check both `defaultValue` authoring gates run (FieldSchema + ActionParam). +export * from './default-value-shape'; export * from './object.zod'; // API-method derivation — the single source of truth turning an object's // `enable.apiMethods` whitelist into its effective operation set (#3391). diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 90d691592c..deac9828a0 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -4,10 +4,14 @@ import { z } from 'zod'; import { retiredKey } from '../shared/retired-key'; import { FieldType } from '../data/field.zod'; // #6970 — the authoring gate on `defaultValue` runs the SAME value contract the -// dispatcher runs at submit. Imported file-directly (never via a barrel): -// `field-value.zod` reaches only `shared/` + `data/`, and `action-params.zod` -// only `data/` + `api/` + `shared/`, so neither can close a cycle back to `ui/`. -import { MULTI_CAPABLE_TYPES, isMultiValueField, valueSchemaFor } from '../data/field-value.zod'; +// dispatcher runs at submit, through the shared `defaultValue` discriminator +// module (#7127 — one module, two consumers; the literal-check core lives +// there). Imported file-directly (never via a barrel): `default-value-shape` +// and `field-value.zod` reach only `shared/` + `data/` + `system/`, and +// `action-params.zod` only `data/` + `api/` + `shared/`, so none can close a +// cycle back to `ui/`. +import { MULTI_CAPABLE_TYPES, isMultiValueField } from '../data/field-value.zod'; +import { checkLiteralDefaultValue } from '../data/default-value-shape'; import { isActionParamValuePresent } from './action-params.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; @@ -429,9 +433,16 @@ export const ActionParamSchema = lazySchema(() => strictObject( // unresolvable type). if (!p.type) return; + // The whole present value goes down the LITERAL branch — deliberately no + // `discriminateDefaultValueShape` here. An action param's default is a pure + // literal (the dialog seeds it verbatim; `serializeParamValues` resolves + // nothing), so a runtime-token or envelope SPELLING is judged as the literal + // it would be at submit — the parity pin at the bottom of + // `action-param-default-value.test.ts` is the contract. The stance is + // recorded in `default-value-shape.ts`'s module note (#7127). const def = { type: p.type, multiple: p.multiple, options: p.options }; - const result = valueSchemaFor(def, 'stored').safeParse(p.defaultValue); - if (result.success) return; + const verdict = checkLiteralDefaultValue(def, p.defaultValue); + if (verdict.ok) return; // ARITY is knowable only when the param states it. A field-backed param // inherits `multiple` from its field, so `{ field: 'owners', type: 'user', @@ -440,11 +451,10 @@ export const ActionParamSchema = lazySchema(() => strictObject( // `multiple` AND the type is one whose arity `multiple` decides, accept // either arity and check only the ELEMENT shape. if (p.field && p.multiple === undefined && MULTI_CAPABLE_TYPES.has(p.type)) { - const flipped = valueSchemaFor({ ...def, multiple: !isMultiValueField(def) }, 'stored'); - if (flipped.safeParse(p.defaultValue).success) return; + if (checkLiteralDefaultValue({ ...def, multiple: !isMultiValueField(def) }, p.defaultValue).ok) return; } - const detail = result.error.issues[0]?.message ?? 'invalid value'; + const detail = verdict.detail ?? 'invalid value'; const key = p.name ?? p.field ?? ''; ctx.addIssue({ code: 'custom', diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index 799f6b47e0..a8bbb49ef3 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -23,6 +23,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies - `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol — hierarchical states, guarded +- `node_modules/@objectstack/spec/src/data/field-value.zod.ts` — Field runtime VALUE-shape contract (ADR-0104 D1). - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) diff --git a/skills/objectstack-api/references/_index.md b/skills/objectstack-api/references/_index.md index 446d3444f8..61861d9ec7 100644 --- a/skills/objectstack-api/references/_index.md +++ b/skills/objectstack-api/references/_index.md @@ -24,6 +24,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/api/error-code-ledger.zod.ts` — Error-Code Ledger (ADR-0112 D3). - `node_modules/@objectstack/spec/src/api/realtime-shared.zod.ts` — Realtime Shared Protocol - `node_modules/@objectstack/spec/src/data/data-engine.zod.ts` — Data Engine Protocol +- `node_modules/@objectstack/spec/src/data/field-value.zod.ts` — Field runtime VALUE-shape contract (ADR-0104 D1). - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/query.zod.ts` — Sort Node diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index 2625927924..1f937a25bc 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -21,6 +21,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies - `node_modules/@objectstack/spec/src/automation/control-flow.zod.ts` — Structured control-flow constructs (ADR-0031) — the **native + AI-authored** +- `node_modules/@objectstack/spec/src/data/field-value.zod.ts` — Field runtime VALUE-shape contract (ADR-0104 D1). - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) diff --git a/skills/objectstack-i18n/references/_index.md b/skills/objectstack-i18n/references/_index.md index 94cc0e79c5..7c85a3632c 100644 --- a/skills/objectstack-i18n/references/_index.md +++ b/skills/objectstack-i18n/references/_index.md @@ -14,6 +14,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies +- `node_modules/@objectstack/spec/src/data/field-value.zod.ts` — Field runtime VALUE-shape contract (ADR-0104 D1). - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) diff --git a/skills/objectstack-query/references/_index.md b/skills/objectstack-query/references/_index.md index 8586f0b0ae..a8c41ea0a6 100644 --- a/skills/objectstack-query/references/_index.md +++ b/skills/objectstack-query/references/_index.md @@ -15,6 +15,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies +- `node_modules/@objectstack/spec/src/data/field-value.zod.ts` — Field runtime VALUE-shape contract (ADR-0104 D1). - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol