Skip to content

Commit e2417fe

Browse files
committed
fix(spec): FieldSchema.defaultValue — discriminate, then judge each shape on its own terms (#7127)
Commit 2 of the sequenced ruling: narrow FieldSchema.defaultValue on the commit-1 discriminator, in the engine's own order — absent (null/undefined; '' is a REAL default, engine presence semantics) → CEL envelope (structural acceptance only) → runtime token (per-token × per-type) → literal (valueSchemaFor(def, 'stored'), the #6970 mechanics one layer down). Token table as adjudicated on the issue: NOW() legal on datetime/date/time (both resolvers + types.mdx already support all three); current_user legal on user + lookup with reference 'sys_user' only (#4560); no token on a multi-value field (both resolve to one scalar). Near-miss spellings ('now', '{current_user}') are suggested inside refusals, never accepted as tokens. The field-value.zod <-> field.zod ESM cycle (dossier §2d) is discharged structurally: AddressSchema moves to field-value.zod (it IS the enforced address value contract) and field.zod re-exports it unchanged — the back-edge becomes type-only (FieldType), erased at runtime. Verified with a real build plus a dist-eval smoke test, not assumption. Census re-run against the IMPLEMENTED gate: objectstack 244 + cloud 127 = 371 shipped field-level defaults, 0 refusals (matches the decision dossier exactly). The two known docs-sample defects (schema.mdx bare CEL source string, currency object literal) are refusals of this gate and are fixed. Regenerated after a real spec build: api-surface + export-origins (dual snapshot — AddressSchema origin move + 4 new exports), docs references, skill refs, strictness ledger (AddressSchema's strip-site row moved files). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
1 parent 191cb1d commit e2417fe

18 files changed

Lines changed: 621 additions & 53 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): `FieldSchema.defaultValue` is discriminated (literal / runtime token / CEL envelope) and each shape is validated on its own terms (#7127)
6+
7+
`FieldSchema.defaultValue` was `z.unknown().optional()` — the same acceptance
8+
hole #6970 closed one layer up on action params, but NARROWER: a field default
9+
is polymorphic by design (a literal, a runtime token `NOW()` / `current_user`,
10+
or a CEL Expression envelope `{ dialect, source }`), so the vocabulary has to
11+
be subtracted BEFORE the literal can be judged. Running the value contract
12+
over the whole key judges a token's spelling as data — right only by accident
13+
(`'current_user'` passes a `user` field as a would-be record id; `'NOW()'`
14+
passes `text` as a plain string while the engine intercepts it and stores an
15+
ISO instant instead).
16+
17+
Per the maintainer's sequenced ruling (2026-08-10), this lands in two steps
18+
inside one release:
19+
20+
1. a shared **discriminator** (`@objectstack/spec/data`,
21+
`default-value-shape.ts`): the engine's own envelope predicate verbatim,
22+
the token predicates, and the shared literal-vs-stored-contract core —
23+
one module, two consumers. The #6970 action-param gate is refactored onto
24+
the shared core with zero behavior change.
25+
2. `FieldSchema.defaultValue` narrowed on top of it, in the engine's own
26+
discrimination order:
27+
- **absent** (`null`/`undefined`) → skipped; `''` is a real default
28+
(engine presence semantics, deliberately not the action-param rule);
29+
- **CEL envelope** → structural acceptance only (the result type is
30+
unknowable at parse time; a wrong one is an ADR-0032 runtime concern);
31+
- **runtime token** → per-token × per-type: `NOW()` on
32+
`datetime`/`date`/`time` (both resolvers and the docs already support
33+
all three); `current_user` on `user` or `lookup` with
34+
`reference: 'sys_user'` (#4560); no token on a multi-value field
35+
(both resolve to one scalar);
36+
- **literal** → the field's own stored value contract
37+
(ADR-0104 D1 `valueSchemaFor(def, 'stored')`) — the #6970 mechanics one
38+
layer down.
39+
40+
Rejections are prescriptive: they name the field, its type, the offending
41+
value verbatim, why it cannot hold, and the legal alternatives — including a
42+
suggested token for predictable near-miss spellings (`'now'`,
43+
`'{current_user}'`), which are suggested but never silently widened into
44+
tokens (a genuinely-intended literal must stay storable).
45+
46+
**Migration surface: zero.** All 371 shipped `defaultValue` declarations
47+
across objectstack + cloud were re-censused against the implemented gate —
48+
0 refusals (239 literals all pass their stored contracts; 131 × `NOW()` all
49+
on `datetime`; 1 × `current_user` on `user`). The only declarations anywhere
50+
that newly refuse are two hand-written docs samples that were already wrong
51+
today (stored verbatim / dropped by the SQL DDL), fixed in this change.
52+
53+
**Stock compatibility.** As with #6970: stored metadata carrying a
54+
nonconforming default keeps loading (the read path runs no Zod validation);
55+
authoritative spec validation lives on the WRITE path and surfaces on reads
56+
as the advisory `_diagnostics` envelope. Loud at authoring, non-fatal at
57+
rest, no conversion owed — there is no mechanical rewrite for "the author
58+
meant something else".
59+
60+
Also moved: `AddressSchema` is now declared in `field-value.zod.ts` (it IS
61+
the enforced address value contract) and re-exported from `field.zod.ts`
62+
unchanged — the move removes the one runtime ESM edge that would otherwise
63+
have closed an evaluation cycle between the two modules.

content/docs/protocol/objectql/schema.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ revenue:
382382
label: Annual Revenue
383383
scale: 2
384384
precision: 18
385-
defaultValue: { value: 0, currency: 'USD' }
385+
defaultValue: 0 # a currency VALUE is a bare number (ADR-0104 D1); the currency code is field config, not part of the value
386386
```
387387

388388
#### Date/Time Fields
@@ -396,7 +396,10 @@ start_date:
396396
due_datetime:
397397
type: datetime
398398
label: Due Date & Time
399-
defaultValue: "daysFromNow(7)" # 7 days from now (CEL)
399+
# 7 days from now — CEL defaults use the Expression ENVELOPE. A bare source
400+
# string is not recognised as an expression: it would be stored verbatim as
401+
# the literal `daysFromNow(7)` (and refused at parse time since #7127).
402+
defaultValue: { dialect: cel, source: "daysFromNow(7)" }
400403
```
401404
402405
#### Boolean Fields

content/docs/references/data/field-value.mdx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,30 @@ this contract has (ADR-0104 performance budget).
4040
## TypeScript Usage
4141

4242
```typescript
43-
import { AddressValueSchema, CalendarDateValueSchema, ClockTimeValueSchema, FileLikeValueSchema, FileReferenceIdValueSchema, FileValueSchema, InstantValueSchema, LocationValueSchema, ReferenceIdValueSchema } from '@objectstack/spec/data';
44-
import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileReferenceIdValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
43+
import { AddressSchema, AddressValueSchema, CalendarDateValueSchema, ClockTimeValueSchema, FileLikeValueSchema, FileReferenceIdValueSchema, FileValueSchema, InstantValueSchema, LocationValueSchema, ReferenceIdValueSchema } from '@objectstack/spec/data';
44+
import type { Address, AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, FileReferenceIdValue, FileValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
4545

4646
// Validate data
47-
const result = AddressValueSchema.parse(data);
47+
const result = AddressSchema.parse(data);
4848
```
4949

50+
---
51+
52+
## Address
53+
54+
### Properties
55+
56+
| Property | Type | Required | Description |
57+
| :--- | :--- | :--- | :--- |
58+
| **street** | `string` | optional | Street address |
59+
| **city** | `string` | optional | City name |
60+
| **state** | `string` | optional | State/Province |
61+
| **postalCode** | `string` | optional | Postal/ZIP code |
62+
| **country** | `string` | optional | Country name or code |
63+
| **countryCode** | `string` | optional | ISO country code (e.g., US, GB) |
64+
| **formatted** | `string` | optional | Formatted address string |
65+
66+
5067
---
5168

5269
## AddressValue

content/docs/references/data/field.mdx

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,13 @@ Field Type Enum
1414
## TypeScript Usage
1515

1616
```typescript
17-
import { AddressSchema, CurrencyConfigSchema, CurrencyValueSchema, FieldSchema, FieldType, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data';
18-
import type { Address, CurrencyConfig, CurrencyValue, Field, FieldType, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data';
17+
import { CurrencyConfigSchema, CurrencyValueSchema, FieldSchema, FieldType, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data';
18+
import type { CurrencyConfig, CurrencyValue, Field, FieldType, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data';
1919

2020
// Validate data
21-
const result = AddressSchema.parse(data);
21+
const result = CurrencyConfigSchema.parse(data);
2222
```
2323

24-
---
25-
26-
## Address
27-
28-
### Properties
29-
30-
| Property | Type | Required | Description |
31-
| :--- | :--- | :--- | :--- |
32-
| **street** | `string` | optional | Street address |
33-
| **city** | `string` | optional | City name |
34-
| **state** | `string` | optional | State/Province |
35-
| **postalCode** | `string` | optional | Postal/ZIP code |
36-
| **country** | `string` | optional | Country name or code |
37-
| **countryCode** | `string` | optional | ISO country code (e.g., US, GB) |
38-
| **formatted** | `string` | optional | Formatted address string |
39-
40-
4124
---
4225

4326
## CurrencyConfig
@@ -81,7 +64,7 @@ const result = AddressSchema.parse(data);
8164
| **searchable** | `boolean` | optional | Is searchable |
8265
| **multiple** | `boolean` | optional | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. |
8366
| **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' |
84-
| **defaultValue** | `any` | optional | Default value |
67+
| **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. |
8568
| **maxLength** | `number` | optional | Max character length |
8669
| **minLength** | `number` | optional | Min character length |
8770
| **precision** | `number` | optional | Total digits |

content/docs/references/index.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye
171171
| [`external-catalog.zod.ts`](/docs/references/data/external-catalog) | `ExternalCatalog`, `ExternalColumn`, `ExternalTable` |
172172
| [`external-lookup.zod.ts`](/docs/references/data/external-lookup) | `ExternalDataSource`, `ExternalFieldMapping`, `ExternalLookup` |
173173
| [`feed.zod.ts`](/docs/references/data/feed) | `FeedFilterMode`, `FeedItemType` |
174-
| [`field.zod.ts`](/docs/references/data/field) | `Address`, `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldType`, `LocationCoordinates`, `SelectOption`, `UniqueScope` |
175-
| [`field-value.zod.ts`](/docs/references/data/field-value) | `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` |
174+
| [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldType`, `LocationCoordinates`, `SelectOption`, `UniqueScope` |
175+
| [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` |
176176
| [`filter.zod.ts`](/docs/references/data/filter) | `EqualityOperator`, `FieldReference`, `FilterArray`, `FilterCondition`, `QueryFilter`, `SetOperator`, `SpecialOperator`, `StringOperator` |
177177
| [`hook.zod.ts`](/docs/references/data/hook) | `HookContext`, `HookEvent` |
178178
| [`hook-body.zod.ts`](/docs/references/data/hook-body) | `ExpressionBody`, `HookBody`, `HookBodyCapability`, `ScriptBody` |

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ Remaining strip sites by class:
2929

3030
| Bucket | Sites |
3131
|---|---|
32-
| authorable — the ruling's forced scope | 41 |
33-
| unresolved — needs a per-schema verdict | 33 |
32+
| authorable — the ruling's forced scope | 40 |
33+
| unresolved — needs a per-schema verdict | 34 |
3434
| wire / open — out of forced scope | 105 |
3535
| no door — no carrier, ADR-0049 territory | 1 |
3636
| 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
9898
| `driver/turso.zod.ts` | 2 |
9999
| `external-catalog.zod.ts` | 4 |
100100
| `external-lookup.zod.ts` | 12 |
101-
| `field-value.zod.ts` | 2 |
102-
| `field.zod.ts` | 11 |
101+
| `field-value.zod.ts` | 3 |
102+
| `field.zod.ts` | 10 |
103103
| `filter.zod.ts` | 11 |
104104
| `hook-body.zod.ts` | 2 |
105105
| `hook.zod.ts` | 7 |
@@ -192,8 +192,8 @@ over it is here.
192192
| `driver/memory.zod.ts` | 5 | 6 |
193193
| `external-catalog.zod.ts` | 4 | 4 |
194194
| `external-lookup.zod.ts` | 12 | 12 |
195-
| `field-value.zod.ts` | 1 | 2 |
196-
| `field.zod.ts` | 3 | 11 |
195+
| `field-value.zod.ts` | 2 | 3 |
196+
| `field.zod.ts` | 2 | 10 |
197197
| `filter.zod.ts` | 11 | 11 |
198198
| `hook.zod.ts` | 5 | 7 |
199199
| `object.zod.ts` | 1 | 20 |
@@ -203,8 +203,8 @@ over it is here.
203203

204204
| Bucket | Sites |
205205
|---|---|
206-
| authorable — the ruling's forced scope | 9 |
207-
| unresolved — needs a per-schema verdict | 33 |
206+
| authorable — the ruling's forced scope | 8 |
207+
| unresolved — needs a per-schema verdict | 34 |
208208
| wire / open — out of forced scope | 66 |
209209
| no door — no carrier, ADR-0049 territory | 0 |
210210
| no gate — carrier live, no parse | 0 |

packages/spec/api-surface/data.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"DEFAULT_VALUE_TOKEN_CURRENT_USER (const)",
106106
"DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)",
107107
"DEFAULT_VALUE_TOKEN_NOW (const)",
108+
"DEFAULT_VALUE_TOKEN_SUGGESTIONS (const)",
108109
"DRIVER_CONFIG_SCHEMAS (const)",
109110
"DRIVER_ID_ALIASES (const)",
110111
"DataEngineAggregateOptions (type)",
@@ -359,6 +360,7 @@
359360
"MysqlConfig (type)",
360361
"MysqlConfigParsed (type)",
361362
"MysqlConfigSchema (const)",
363+
"NOW_DEFAULT_LEGAL_TYPES (const)",
362364
"NUMERIC_VALUE_TYPES (const)",
363365
"NoSQLDataTypeMapping (type)",
364366
"NoSQLDataTypeMappingSchema (const)",
@@ -599,6 +601,7 @@
599601
"classifyFilterToken (function)",
600602
"countAuthorableFields (function)",
601603
"defaultAggregateFor (function)",
604+
"defaultValueTokenIssue (function)",
602605
"defineCube (function)",
603606
"defineDatasource (function)",
604607
"defineHook (function)",
@@ -680,6 +683,7 @@
680683
"resolveSearchFields (function)",
681684
"sequenceWidth (function)",
682685
"stripLegacyApiMethods (function)",
686+
"suggestDefaultValueToken (function)",
683687
"suggestFieldTypeForSqlType (function)",
684688
"utcInstantMs (function)",
685689
"validateDriverConfig (function)",

packages/spec/export-origins/data.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"API_PRIMITIVES": "src/data/api-derivation.ts#API_PRIMITIVES (const)",
1212
"AUDIT_PROVENANCE_FIELDS": "src/data/field-group-layout.ts#AUDIT_PROVENANCE_FIELDS (const)",
1313
"Address": "src/data/field.zod.ts#Address (type)",
14-
"AddressSchema": "src/data/field.zod.ts#AddressSchema (const)",
14+
"AddressSchema": "src/data/field-value.zod.ts#AddressSchema (const)",
1515
"AddressValue": "src/data/field-value.zod.ts#AddressValue (type)",
1616
"AddressValueSchema": "src/data/field-value.zod.ts#AddressValueSchema (const)",
1717
"AggregationCase": "src/data/aggregation-conformance.ts#AggregationCase (interface)",
@@ -105,6 +105,7 @@
105105
"DEFAULT_VALUE_TOKEN_CURRENT_USER": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_CURRENT_USER (const)",
106106
"DEFAULT_VALUE_TOKEN_DESCRIPTIONS": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)",
107107
"DEFAULT_VALUE_TOKEN_NOW": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_NOW (const)",
108+
"DEFAULT_VALUE_TOKEN_SUGGESTIONS": "src/data/default-value-shape.ts#DEFAULT_VALUE_TOKEN_SUGGESTIONS (const)",
108109
"DRIVER_CONFIG_SCHEMAS": "src/data/driver/config-registry.zod.ts#DRIVER_CONFIG_SCHEMAS (const)",
109110
"DRIVER_ID_ALIASES": "src/data/driver/config-registry.zod.ts#DRIVER_ID_ALIASES (const)",
110111
"DataEngineAggregateOptions": "src/data/data-engine.zod.ts#DataEngineAggregateOptions (type)",
@@ -359,6 +360,7 @@
359360
"MysqlConfig": "src/data/driver/mysql.zod.ts#MysqlConfig (type)",
360361
"MysqlConfigParsed": "src/data/driver/mysql.zod.ts#MysqlConfigParsed (type)",
361362
"MysqlConfigSchema": "src/data/driver/mysql.zod.ts#MysqlConfigSchema (const)",
363+
"NOW_DEFAULT_LEGAL_TYPES": "src/data/default-value-shape.ts#NOW_DEFAULT_LEGAL_TYPES (const)",
362364
"NUMERIC_VALUE_TYPES": "src/data/field-value.zod.ts#NUMERIC_VALUE_TYPES (const)",
363365
"NoSQLDataTypeMapping": "src/data/driver-nosql.zod.ts#NoSQLDataTypeMapping (type)",
364366
"NoSQLDataTypeMappingSchema": "src/data/driver-nosql.zod.ts#NoSQLDataTypeMappingSchema (const)",
@@ -599,6 +601,7 @@
599601
"classifyFilterToken": "src/data/context-tokens.zod.ts#classifyFilterToken (function)",
600602
"countAuthorableFields": "src/data/record-surface.ts#countAuthorableFields (function)",
601603
"defaultAggregateFor": "src/data/aggregation-policy.ts#defaultAggregateFor (function)",
604+
"defaultValueTokenIssue": "src/data/default-value-shape.ts#defaultValueTokenIssue (function)",
602605
"defineCube": "src/data/analytics.zod.ts#defineCube (function)",
603606
"defineDatasource": "src/data/datasource.zod.ts#defineDatasource (function)",
604607
"defineHook": "src/data/hook.zod.ts#defineHook (function)",
@@ -680,6 +683,7 @@
680683
"resolveSearchFields": "src/data/search-fields.ts#resolveSearchFields (function)",
681684
"sequenceWidth": "src/data/autonumber-format.ts#sequenceWidth (function)",
682685
"stripLegacyApiMethods": "src/data/object.zod.ts#stripLegacyApiMethods (function)",
686+
"suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)",
683687
"suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)",
684688
"utcInstantMs": "src/data/calendar-day.ts#utcInstantMs (function)",
685689
"validateDriverConfig": "src/data/driver/config-registry.zod.ts#validateDriverConfig (function)",

packages/spec/scripts/strictness-ledger.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,11 @@ describe('site counting reads the AST, not the source text', () => {
123123
});
124124

125125
it('knows every object idiom, including z.looseObject(', () => {
126+
// 2 → 3 at #7127, which MOVED `AddressSchema` (a plain z.object site) in
127+
// from `field.zod.ts`. As with the chart count above, the number is
128+
// incidental — the assertion that carries the meaning is the idiom read.
126129
const fv = analyzeSites(at('data/field-value.zod.ts'));
127-
expect(fv).toHaveLength(2);
130+
expect(fv).toHaveLength(3);
128131
expect(fv.find((s) => s.name === 'FileValueSchema')?.idiom).toBe('z.looseObject');
129132
});
130133
});

0 commit comments

Comments
 (0)