diff --git a/.changeset/adr-0104-d1-value-shape-contract.md b/.changeset/adr-0104-d1-value-shape-contract.md
new file mode 100644
index 0000000000..8f677d3213
--- /dev/null
+++ b/.changeset/adr-0104-d1-value-shape-contract.md
@@ -0,0 +1,41 @@
+---
+"@objectstack/spec": minor
+"@objectstack/objectql": minor
+"@objectstack/rest": patch
+"@objectstack/driver-sql": patch
+---
+
+feat(spec): field runtime value-shape contract — ADR-0104 phase 1 (D1)
+
+`@objectstack/spec/data` now owns the runtime VALUE shape of every field type
+(`field-value.zod.ts`): semantic type classes (`STRING_VALUE_TYPES`,
+`NUMERIC_VALUE_TYPES`, `REFERENCE_VALUE_TYPES`, `FILE_REFERENCE_TYPES`,
+`STRUCTURED_JSON_TYPES`, `MULTI_CAPABLE_TYPES`, …), the shared
+`isMultiValueField`, and `valueSchemaFor(field, 'stored' | 'expanded')`. The
+four consumers that each hand-copied this knowledge (objectql record-validator,
+rest import-coerce, driver-sql column classification, qa conformance) now
+derive from the spec, and the field-zoo round-trip MATRIX is asserted against
+the contract so the two cannot drift.
+
+**Write-path change (objectql, warn-first):** previously-unvalidated types —
+single `lookup`/`master_detail`/`user`/`tree`, `file`/`image`/`avatar`/
+`video`/`audio`, `location`, `address`, `composite`, `repeater`, `record`,
+`vector` — are now checked against the contract. A violation **logs a warning
+and passes** in this release (legacy rows must not strand their records);
+set `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` to enforce as a
+`400 VALIDATION_FAILED`. The flip to strict-by-default rides a later minor
+(ADR-0104 R1/R2).
+
+**Deprecations (removal rides the next spec major), FROM → TO:**
+
+- `CurrencyValueSchema` (`{value, currency}`) → none. A `currency` field's
+ value is a **bare number** everywhere in the runtime (validator, SQL `float`
+ column, import coercion, field-zoo oracle); the currency code lives in field
+ config. Use `valueSchemaFor({type: 'currency'})`.
+- `LocationCoordinatesSchema` (`{latitude, longitude}`) → `LocationValueSchema`
+ (`{lat, lng}`) — the shape the platform actually stores.
+- `AddressSchema` is **adopted** (unchanged) as the enforced `address` value
+ contract via `AddressValueSchema`.
+
+No stored data changes shape; the contract codifies deployed reality
+("reality wins", ADR-0104 D1).
diff --git a/content/docs/references/data/field-value.mdx b/content/docs/references/data/field-value.mdx
new file mode 100644
index 0000000000..8db95f2643
--- /dev/null
+++ b/content/docs/references/data/field-value.mdx
@@ -0,0 +1,141 @@
+---
+title: Field Value
+description: Field Value protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+Field runtime VALUE-shape contract (ADR-0104 D1).
+
+`FieldSchema` owns what a field *definition* looks like; this module owns
+
+what a field's runtime *value* looks like — the shape the write path
+
+accepts, drivers persist, and an unexpanded API read returns. Before this
+
+module the knowledge lived as private, hand-copied type sets in objectql's
+
+record-validator, rest's import-coerce, driver-sql, and verify; adding one
+
+multi-capable or JSON-shaped type meant updating four lists or silently
+
+corrupting data. Those consumers now derive from the classes below.
+
+Two canonical forms exist per field (ADR-0104 D1):
+
+- `stored` — the storage/wire form (e.g. lookup ⇒ record-id string,
+
+`date` ⇒ `YYYY-MM-DD`, select ⇒ option code).
+
+- `expanded` — the enriched `$expand` read form (lookup ⇒ the related
+
+record object). For types without an expansion,
+
+expanded ≡ stored.
+
+"Reality wins": where the deployed stored shape is coherent, the contract
+
+adopts it — deployed data is a wire contract we don't get to rewrite by
+
+editing Zod. This is why `currency` is a bare number (not the never-consumed
+
+`CurrencyValueSchema` object) and `location` is `\{lat, lng\}` (what field-zoo
+
+stores), not the never-consumed `\{latitude, longitude\}` shape.
+
+Purity: schemas/constants/derivation only — no runtime logic, no caching
+
+(Prime Directive #2). Consumers cache `valueSchemaFor` results per field
+
+definition; building a Zod schema per write is the one performance trap
+
+this contract has (ADR-0104 performance budget).
+
+
+**Source:** `packages/spec/src/data/field-value.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
+import type { AddressValue, CalendarDateValue, ClockTimeValue, FileLikeValue, InstantValue, LocationValue, ReferenceIdValue } from '@objectstack/spec/data';
+
+// Validate data
+const result = AddressValue.parse(data);
+```
+
+---
+
+## AddressValue
+
+### 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 |
+
+
+---
+
+
+---
+
+
+---
+
+## FileLikeValue
+
+### Union Options
+
+This schema accepts one of the following structures:
+
+#### Option 1
+
+Type: `string`
+
+---
+
+#### Option 2
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **url** | `string` | optional | |
+| **name** | `string` | optional | |
+| **size** | `number` | optional | |
+| **alt** | `string` | optional | |
+| **duration** | `number` | optional | |
+
+---
+
+
+---
+
+
+---
+
+## LocationValue
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **lat** | `number` | ✅ | Latitude |
+| **lng** | `number` | ✅ | Longitude |
+| **altitude** | `number` | optional | Altitude in meters |
+| **accuracy** | `number` | optional | Accuracy in meters |
+
+
+---
+
+
+---
+
diff --git a/content/docs/references/data/index.mdx b/content/docs/references/data/index.mdx
index 9498ac5742..9d9f6e28db 100644
--- a/content/docs/references/data/index.mdx
+++ b/content/docs/references/data/index.mdx
@@ -18,6 +18,7 @@ This section contains all protocol schemas for the data layer of ObjectStack.
+
diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json
index 3b7f0e4678..9f551ea1b0 100644
--- a/content/docs/references/data/meta.json
+++ b/content/docs/references/data/meta.json
@@ -25,6 +25,8 @@
"document",
"feed",
"seed",
- "seed-loader"
+ "seed-loader",
+ "---More---",
+ "field-value"
]
}
\ No newline at end of file
diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts
index 1364f9cabe..daf35b7ef4 100644
--- a/packages/objectql/src/validation/record-validator.test.ts
+++ b/packages/objectql/src/validation/record-validator.test.ts
@@ -345,3 +345,63 @@ describe('validateRecord — url field accepts relative + inline URLs', () => {
expect(() => validateRecord(schema, { image: 'notaurl' }, 'update')).toThrow(/valid URL/i);
});
});
+
+/**
+ * ADR-0104 D1 — value-shape contract for previously-opaque types.
+ *
+ * Warn-first rollout: a shape violation on reference/file/structured-JSON
+ * types logs (once per field) and passes; `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`
+ * turns it into a normal invalid_type rejection.
+ */
+describe('validateRecord — ADR-0104 value shapes (warn-first / strict)', () => {
+ const schema = {
+ fields: {
+ account: { type: 'lookup', reference: 'accounts' },
+ geo: { type: 'location' },
+ doc: { type: 'file' },
+ dims: { type: 'vector' },
+ },
+ };
+
+ const withStrict = (fn: () => void) => {
+ process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED = '1';
+ try { fn(); } finally { delete process.env.OS_DATA_VALUE_SHAPE_STRICT_ENABLED; }
+ };
+
+ it('accepts contract-conformant values in both modes', () => {
+ const data = {
+ account: 'acc_0001',
+ geo: { lat: 37.77, lng: -122.42 },
+ doc: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 },
+ dims: [0.1, 0.2],
+ };
+ expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow();
+ withStrict(() => expect(() => validateRecord(schema, { ...data }, 'update')).not.toThrow());
+ });
+
+ it('warn-first: malformed shapes pass by default (legacy rows must not strand records)', () => {
+ expect(() => validateRecord(schema, { geo: { latitude: 1, longitude: 2 } }, 'update')).not.toThrow();
+ expect(() => validateRecord(schema, { account: { id: 'acc_1' } }, 'update')).not.toThrow();
+ });
+
+ it('strict: malformed shapes reject with invalid_type', () => {
+ withStrict(() => {
+ try {
+ validateRecord(schema, { geo: { latitude: 37.77, longitude: -122.42 } }, 'update');
+ expect.unreachable('expected ValidationError');
+ } catch (e) {
+ expect(e).toBeInstanceOf(ValidationError);
+ const err = e as ValidationError;
+ expect(err.fields[0]?.field).toBe('geo');
+ expect(err.fields[0]?.code).toBe('invalid_type');
+ }
+ // expanded-form object at a stored-form position (unexpanded write)
+ expect(() => validateRecord(schema, { account: { id: 'acc_1', name: 'Acme' } }, 'update')).toThrow(ValidationError);
+ // scalar at a vector
+ expect(() => validateRecord(schema, { dims: 'not-a-vector' }, 'update')).toThrow(ValidationError);
+ // file: id/url string AND inline object both remain legal pre-D3
+ expect(() => validateRecord(schema, { doc: 'file_01HXYZ' }, 'update')).not.toThrow();
+ expect(() => validateRecord(schema, { doc: 42 }, 'update')).toThrow(ValidationError);
+ });
+ });
+});
diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts
index 2c81c1ea17..3bcec2de3b 100644
--- a/packages/objectql/src/validation/record-validator.ts
+++ b/packages/objectql/src/validation/record-validator.ts
@@ -31,6 +31,14 @@
* the UI can highlight the specific input.
*/
+import {
+ isMultiValueField as specIsMultiValueField,
+ valueSchemaFor,
+ REFERENCE_VALUE_TYPES,
+ FILE_REFERENCE_TYPES,
+ STRUCTURED_JSON_TYPES,
+} from '@objectstack/spec/data';
+
// Lifecycle columns the engine always owns and the client never supplies. These
// are skipped by NAME because they are not author-declared business fields.
// NOTE: `organization_id` / `tenant_id` are intentionally NOT here (#1592) — the
@@ -145,17 +153,11 @@ function optionValues(options: FieldDef['options']): string[] {
/**
* A field whose persisted value is an ARRAY of scalars: either an
* inherently-multi type, or a single-value type flagged `multiple: true`.
- * Per the spec (field.zod.ts), `multiple` applies to select/lookup/file/image;
- * `radio` shares the select branch and `user` is stored identically to
- * `lookup` (FK column, `multiple` ⇒ JSON array) — the runtime expands
- * `Field.user` with `type: 'user'`, so it must be recognized here too.
+ * THE definition is the spec's (ADR-0104 D1) — previously a hand-copy here
+ * and in rest/import-coerce that had to be kept in lock-step manually.
*/
-const MULTI_CAPABLE_TYPES = new Set(['select', 'radio', 'lookup', 'user', 'file', 'image']);
-
function isMultiValueField(def: FieldDef): boolean {
- const t = def.type;
- if (t === 'multiselect' || t === 'checkboxes' || t === 'tags') return true;
- return MULTI_CAPABLE_TYPES.has(t as string) && def.multiple === true;
+ return specIsMultiValueField(def as { type: string; multiple?: boolean });
}
/**
@@ -223,12 +225,12 @@ export function coerceBooleanFields>(
return (copy ?? row) as T;
}
-function validateOne(name: string, def: FieldDef, value: unknown): FieldValidationError | null {
+function validateOne(name: string, def: FieldDef, value: unknown, skipRequired = false): FieldValidationError | null {
// ── required ────────────────────────────────────────────────────
// `autonumber` is runtime-owned: the value is generated by the engine /
// driver (the SQL driver assigns it from a persistent sequence AFTER this
// validation runs), so a missing value is never a client error — see #1603.
- if (def.required && isMissing(value) && def.type !== 'autonumber') {
+ if (!skipRequired && def.required && isMissing(value) && def.type !== 'autonumber') {
return { field: name, code: 'required', message: `${name} is required` };
}
if (isMissing(value)) return null; // nothing else to check
@@ -338,12 +340,62 @@ function validateOne(name: string, def: FieldDef, value: unknown): FieldValidati
return null;
}
- // Other types (lookup, file, formula, json, location, etc.) — no
- // strict shape check at this layer; reference integrity is handled
- // elsewhere (lookup) and the rest are opaque payloads.
+ // ── previously-opaque types: value-shape contract (ADR-0104 D1) ─────
+ // Single-value references (id string), file-likes (id/url string or the
+ // pre-D3 inline metadata object), and structured JSON payloads
+ // (location/address/composite/repeater/record/vector) are checked against
+ // the spec's `valueSchemaFor`. Warn-first rollout (ADR-0104 R1/R2): a
+ // violation logs instead of rejecting, so legacy rows written under the
+ // lax regime don't strand their records; set
+ // `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` to enforce as a 400. The flip to
+ // error-by-default rides a later minor once telemetry is quiet.
+ if (REFERENCE_VALUE_TYPES.has(t) || FILE_REFERENCE_TYPES.has(t) || STRUCTURED_JSON_TYPES.has(t)) {
+ const parsed = shapeSchemaFor(def).safeParse(value);
+ if (!parsed.success) {
+ const detail = parsed.error.issues[0]?.message ?? 'invalid value shape';
+ const message = `${name} has an invalid ${t} value: ${detail}`;
+ if (VALUE_SHAPE_STRICT()) {
+ return { field: name, code: 'invalid_type', message };
+ }
+ warnOnce(`${t}:${name}`, `[value-shape] ${message} — accepted for now (ADR-0104 warn-first; set OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1 to enforce)`);
+ }
+ return null;
+ }
+
+ // Remaining types (formula/summary/autonumber outputs, json/code payloads)
+ // are explicitly open per the spec contract — see field-value.zod.ts.
return null;
}
+/** Strict value-shape enforcement opt-in (ADR-0104 warn-first rollout). */
+function VALUE_SHAPE_STRICT(): boolean {
+ return typeof process !== 'undefined' && process.env?.OS_DATA_VALUE_SHAPE_STRICT_ENABLED === '1';
+}
+
+const warnedShapes = new Set();
+function warnOnce(key: string, message: string): void {
+ if (warnedShapes.has(key)) return;
+ warnedShapes.add(key);
+ console.warn(message);
+}
+
+/**
+ * Per-field-definition cache of the spec's derived value schema. Building a
+ * Zod schema is an order of magnitude costlier than parsing with it, so the
+ * derivation runs once per field def (ADR-0104 performance budget). Keyed on
+ * def identity — `validateRecord`'s update path passes the registry's own
+ * field objects (no clones), so the map hits.
+ */
+const shapeSchemaCache = new WeakMap>();
+function shapeSchemaFor(def: FieldDef): ReturnType {
+ let schema = shapeSchemaCache.get(def);
+ if (!schema) {
+ schema = valueSchemaFor(def as { type: string; multiple?: boolean; options?: FieldDef['options'] }, 'stored');
+ shapeSchemaCache.set(def, schema);
+ }
+ return schema;
+}
+
/**
* Validate a payload against a list of declared fields. `objectSchema`
* comes from `ObjectQL.getRegistry().getObject(name)` and exposes a
@@ -377,8 +429,10 @@ export function validateRecord(
const def = fields[name];
if (!def) continue;
if (def.system || def.readonly) continue;
- // Clone def with required=false so PATCH-omitted-fields don't 400.
- const err = validateOne(name, { ...def, required: false }, value);
+ // skipRequired: PATCH-omitted fields must not 400. (No def clone — the
+ // registry's own field object flows through so the ADR-0104 value-shape
+ // schema cache, keyed on def identity, hits.)
+ const err = validateOne(name, def, value, true);
if (err) errors.push(err);
}
}
diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts
index 3fcfe0736d..0bc4b456ab 100644
--- a/packages/plugins/driver-sql/src/sql-driver.ts
+++ b/packages/plugins/driver-sql/src/sql-driver.ts
@@ -9,6 +9,7 @@
import type { QueryAST, DriverOptions, SchemaMode } from '@objectstack/spec/data';
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data';
+import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data';
import type { IDataDriver } from '@objectstack/spec/contracts';
import { StorageNameMapping } from '@objectstack/spec/system';
import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared';
@@ -57,10 +58,14 @@ const GLOBAL_TENANT = '__global__';
* rest are arrays.
*/
const JSON_COLUMN_TYPES = new Set([
- 'json', 'object', 'array', 'record',
- 'image', 'file', 'avatar', 'video', 'audio',
- 'location', 'address', 'composite',
- 'multiselect', 'checkboxes', 'tags', 'repeater', 'vector',
+ // Spec value-shape classes (ADR-0104 D1): structured JSON payloads, the
+ // (pre-D3) inline file metadata objects, and the inherently-array option
+ // types. Membership is owned by @objectstack/spec — a type added there
+ // becomes a JSON column here without touching this file.
+ ...STRUCTURED_JSON_TYPES, ...FILE_REFERENCE_TYPES, ...MULTI_OPTION_TYPES,
+ // Driver-internal aliases (external/introspected columns) — not authorable
+ // FieldTypes, so they stay a local extra.
+ 'object', 'array',
]);
/**
@@ -79,9 +84,9 @@ const JSON_COLUMN_TYPES = new Set([
* gap for the numeric scalars.
*/
const NUMERIC_SCALAR_TYPES = new Set([
- 'integer', 'int',
- 'float', 'number', 'currency', 'percent', 'summary',
- 'rating', 'slider', 'progress',
+ // Spec numeric value class (ADR-0104 D1) + driver-internal SQL aliases.
+ ...NUMERIC_VALUE_TYPES,
+ 'integer', 'int', 'float',
]);
/**
diff --git a/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts b/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts
index 2c4a072afb..9b7d741361 100644
--- a/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts
+++ b/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts
@@ -21,103 +21,7 @@ import showcaseStack from '@objectstack/example-showcase';
import { SECRET_MASK } from '@objectstack/objectql';
import { bootStack, type VerifyStack } from '@objectstack/verify';
-// A field-type coverage entry. `write` is the value POSTed; `expect` describes
-// how the value must come back. `equal` = exact (or set-equal for arrays);
-// `computed`/`present` cover server-owned fields you don't write; `masked`
-// covers `secret`, which is encrypt-on-write and masked on read.
-type Check =
- | { kind: 'equal'; write: unknown }
- | { kind: 'setEqual'; write: unknown[] }
- | { kind: 'present'; write?: unknown } // written but only asserted non-null (e.g. one-way/opaque)
- | { kind: 'masked'; write: unknown } // secret: POSTed plaintext must read back as SECRET_MASK
- | { kind: 'computed'; expected: unknown }; // derived, asserted not written
-
-interface FieldCase {
- field: string;
- type: string;
- check: Check;
- // KNOWN GAP: the schema→SQL-column mapping / read coercion doesn't yet cover
- // this type, so it round-trips with the wrong JS type (e.g. '4' not 4). The
- // value persists (no data loss), but fidelity leaks. Quarantined via it.fails
- // so it stays visible AND auto-flags the day it's fixed. Tracked separately.
- xfail?: boolean;
-}
-
-// The #2004 headliners are the three array types + f_time. The rest broaden the
-// matrix across scalars, temporals, structured JSON, and computed/system fields.
-const MATRIX: FieldCase[] = [
- // text-ish
- { field: 'f_textarea', type: 'textarea', check: { kind: 'equal', write: 'line1\nline2' } },
- { field: 'f_email', type: 'email', check: { kind: 'equal', write: 'zoo@example.com' } },
- { field: 'f_url', type: 'url', check: { kind: 'equal', write: 'https://objectstack.ai' } },
- { field: 'f_phone', type: 'phone', check: { kind: 'equal', write: '+14155550123' } },
- // numbers
- { field: 'f_number', type: 'number', check: { kind: 'equal', write: 42 } },
- { field: 'f_currency', type: 'currency', check: { kind: 'equal', write: 1234.56 } },
- { field: 'f_percent', type: 'percent', check: { kind: 'equal', write: 75 } },
- { field: 'f_rating', type: 'rating', check: { kind: 'equal', write: 4 } },
- { field: 'f_slider', type: 'slider', check: { kind: 'equal', write: 25 } },
- // temporal — f_time is a #2004 fix (time-of-day)
- { field: 'f_date', type: 'date', check: { kind: 'equal', write: '2024-03-15' } },
- { field: 'f_time', type: 'time', check: { kind: 'equal', write: '14:30:00' } },
- // logic
- { field: 'f_boolean', type: 'boolean', check: { kind: 'equal', write: true } },
- { field: 'f_toggle', type: 'toggle', check: { kind: 'equal', write: true } },
- // scalar selection
- { field: 'f_select', type: 'select', check: { kind: 'equal', write: 'high' } },
- { field: 'f_radio', type: 'radio', check: { kind: 'equal', write: 'yes' } },
- // ── #2004 array headliners — these silently dropped before the fix ──
- { field: 'f_multiselect', type: 'multiselect', check: { kind: 'setEqual', write: ['red', 'blue'] } },
- { field: 'f_checkboxes', type: 'checkboxes', check: { kind: 'setEqual', write: ['email', 'push'] } },
- { field: 'f_tags', type: 'tags', check: { kind: 'setEqual', write: ['alpha', 'beta', 'gamma'] } },
- // numeric scalar — same fidelity class as rating/slider (was TEXT-affinity)
- { field: 'f_progress', type: 'progress', check: { kind: 'equal', write: 60 } },
- // rich text — all plain strings on the wire
- { field: 'f_markdown', type: 'markdown', check: { kind: 'equal', write: '# Heading\n\nbody' } },
- { field: 'f_html', type: 'html', check: { kind: 'equal', write: 'hi
' } },
- { field: 'f_richtext', type: 'richtext', check: { kind: 'equal', write: 'rich' } },
- { field: 'f_code', type: 'code', check: { kind: 'equal', write: '{\n "a": 1\n}' } },
- { field: 'f_signature', type: 'signature', check: { kind: 'equal', write: 'data:image/png;base64,AAAA' } },
- { field: 'f_qrcode', type: 'qrcode', check: { kind: 'equal', write: 'https://objectstack.ai' } },
- // temporal — instant
- { field: 'f_datetime', type: 'datetime', check: { kind: 'equal', write: '2024-03-15T14:30:00.000Z' } },
- // structured JSON
- { field: 'f_json', type: 'json', check: { kind: 'equal', write: { a: 1, b: [2, 3] } } },
- { field: 'f_color', type: 'color', check: { kind: 'equal', write: '#FF8800' } },
- { field: 'f_vector', type: 'vector', check: { kind: 'equal', write: [0.1, 0.2, 0.3] } },
- // object-valued types that must store/parse as JSON, not stringify to TEXT
- { field: 'f_record', type: 'record', check: { kind: 'equal', write: { home: '+1', work: '+2' } } },
- { field: 'f_video', type: 'video', check: { kind: 'equal', write: { url: 'https://cdn/v.mp4', duration: 12 } } },
- { field: 'f_audio', type: 'audio', check: { kind: 'equal', write: { url: 'https://cdn/a.mp3', duration: 30 } } },
- { field: 'f_composite', type: 'composite', check: { kind: 'equal', write: { label: 'x', n: 1 } } },
- { field: 'f_repeater', type: 'repeater', check: { kind: 'equal', write: [{ a: 1 }, { a: 2 }] } },
- { field: 'f_location', type: 'location', check: { kind: 'equal', write: { lat: 37.77, lng: -122.42 } } },
- { field: 'f_address', type: 'address', check: { kind: 'equal', write: { street: '1 Main', city: 'SF', country: 'US' } } },
- { field: 'f_image', type: 'image', check: { kind: 'equal', write: { url: 'https://cdn/i.png', alt: 'i' } } },
- { field: 'f_file', type: 'file', check: { kind: 'equal', write: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 } } },
- { field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: { url: 'https://cdn/a.png' } } },
- // relational — store a reference id as a string and read it back verbatim.
- // FK enforcement is off in this harness, so this asserts value fidelity
- // (id string → id string), not referential integrity / $expand (covered
- // elsewhere). The point here is the stored type doesn't drift.
- { field: 'f_lookup', type: 'lookup', check: { kind: 'equal', write: 'acc_synthetic_0001' } },
- { field: 'f_master_detail', type: 'master_detail', check: { kind: 'equal', write: 'proj_synthetic_0001' } },
- { field: 'f_tree', type: 'tree', check: { kind: 'equal', write: 'cat_synthetic_0001' } },
- // security — both credential types mask on read (plaintext never echoes back
- // over HTTP). `secret` is encrypted at rest; `password` on a generic object is
- // plaintext at rest but masked to SECRET_MASK on read (ADR-0100 / #2036 — the
- // generic path does NOT hash it; auth owns hashing for its identity tables).
- { field: 'f_secret', type: 'secret', check: { kind: 'masked', write: 'topsecret-value' } },
- { field: 'f_password', type: 'password', check: { kind: 'masked', write: 'p@ssw0rd!' } },
- // NB: f_summary (roll-up) is intentionally absent — it's a computed
- // aggregate over related records, null on a childless fixture row; its
- // semantics are covered by dedicated roll-up tests, not value fidelity.
- // computed / system — not written, must materialize
- { field: 'f_autonumber', type: 'autonumber', check: { kind: 'present' } },
- // f_number(42) * f_percent(75) / 100 = 31.5
- { field: 'f_formula', type: 'formula', check: { kind: 'computed', expected: 31.5 } },
-];
-
+import { MATRIX } from './field-zoo.matrix';
describe('dogfood: field-type capability matrix round-trips over HTTP (#2004)', () => {
let stack: VerifyStack;
let record: Record;
diff --git a/packages/qa/dogfood/test/field-zoo-value-shape.test.ts b/packages/qa/dogfood/test/field-zoo-value-shape.test.ts
new file mode 100644
index 0000000000..5a9a91b57b
--- /dev/null
+++ b/packages/qa/dogfood/test/field-zoo-value-shape.test.ts
@@ -0,0 +1,38 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// ADR-0104 D1 — contract ⇔ oracle interlock.
+//
+// The field-zoo MATRIX is the executable oracle of what each FieldType stores
+// (driven over real HTTP by field-zoo-roundtrip.dogfood.test.ts). The spec's
+// `valueSchemaFor` is the declared value-shape contract. This test pins them
+// together: every MATRIX write vector must parse under the contract's stored
+// form. A contract change that would break the wire — or a MATRIX change the
+// contract doesn't admit — fails here instead of shipping silently.
+//
+// No stack boot: this is a pure unit-level assertion, cheap enough for every
+// CI run (the HTTP round-trip stays in the dogfood tier).
+
+import { describe, it, expect } from 'vitest';
+import { valueSchemaFor } from '@objectstack/spec/data';
+import { MATRIX } from './field-zoo.matrix';
+
+describe('ADR-0104: field-zoo MATRIX write vectors parse under valueSchemaFor(stored)', () => {
+ const writable = MATRIX.filter(
+ (c) => c.check.kind === 'equal' || c.check.kind === 'setEqual' || c.check.kind === 'masked',
+ );
+
+ it('covers a meaningful slice of the matrix', () => {
+ expect(writable.length).toBeGreaterThanOrEqual(40);
+ });
+
+ for (const c of writable) {
+ it(`${c.field} (${c.type})`, () => {
+ const write = (c.check as { write: unknown }).write;
+ const result = valueSchemaFor({ type: c.type }, 'stored').safeParse(write);
+ expect(
+ result.success,
+ result.success ? undefined : `${c.type} write ${JSON.stringify(write)} rejected: ${result.error.issues[0]?.message}`,
+ ).toBe(true);
+ });
+ }
+});
diff --git a/packages/qa/dogfood/test/field-zoo.matrix.ts b/packages/qa/dogfood/test/field-zoo.matrix.ts
new file mode 100644
index 0000000000..8931479642
--- /dev/null
+++ b/packages/qa/dogfood/test/field-zoo.matrix.ts
@@ -0,0 +1,105 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// The field-zoo capability MATRIX — the executable oracle of what each
+// authorable FieldType stores on the wire. Extracted from
+// field-zoo-roundtrip.dogfood.test.ts (which drives it over real HTTP) so the
+// ADR-0104 value-shape contract test can assert against the SAME vectors
+// without booting a stack: the contract (@objectstack/spec valueSchemaFor)
+// and this oracle cannot drift apart without a test going red.
+
+// A field-type coverage entry. `write` is the value POSTed; `expect` describes
+// how the value must come back. `equal` = exact (or set-equal for arrays);
+// `computed`/`present` cover server-owned fields you don't write; `masked`
+// covers `secret`, which is encrypt-on-write and masked on read.
+export type Check =
+ | { kind: 'equal'; write: unknown }
+ | { kind: 'setEqual'; write: unknown[] }
+ | { kind: 'present'; write?: unknown } // written but only asserted non-null (e.g. one-way/opaque)
+ | { kind: 'masked'; write: unknown } // secret: POSTed plaintext must read back as SECRET_MASK
+ | { kind: 'computed'; expected: unknown }; // derived, asserted not written
+
+export interface FieldCase {
+ field: string;
+ type: string;
+ check: Check;
+ // KNOWN GAP: the schema→SQL-column mapping / read coercion doesn't yet cover
+ // this type, so it round-trips with the wrong JS type (e.g. '4' not 4). The
+ // value persists (no data loss), but fidelity leaks. Quarantined via it.fails
+ // so it stays visible AND auto-flags the day it's fixed. Tracked separately.
+ xfail?: boolean;
+}
+
+// The #2004 headliners are the three array types + f_time. The rest broaden the
+// matrix across scalars, temporals, structured JSON, and computed/system fields.
+export const MATRIX: FieldCase[] = [
+ // text-ish
+ { field: 'f_textarea', type: 'textarea', check: { kind: 'equal', write: 'line1\nline2' } },
+ { field: 'f_email', type: 'email', check: { kind: 'equal', write: 'zoo@example.com' } },
+ { field: 'f_url', type: 'url', check: { kind: 'equal', write: 'https://objectstack.ai' } },
+ { field: 'f_phone', type: 'phone', check: { kind: 'equal', write: '+14155550123' } },
+ // numbers
+ { field: 'f_number', type: 'number', check: { kind: 'equal', write: 42 } },
+ { field: 'f_currency', type: 'currency', check: { kind: 'equal', write: 1234.56 } },
+ { field: 'f_percent', type: 'percent', check: { kind: 'equal', write: 75 } },
+ { field: 'f_rating', type: 'rating', check: { kind: 'equal', write: 4 } },
+ { field: 'f_slider', type: 'slider', check: { kind: 'equal', write: 25 } },
+ // temporal — f_time is a #2004 fix (time-of-day)
+ { field: 'f_date', type: 'date', check: { kind: 'equal', write: '2024-03-15' } },
+ { field: 'f_time', type: 'time', check: { kind: 'equal', write: '14:30:00' } },
+ // logic
+ { field: 'f_boolean', type: 'boolean', check: { kind: 'equal', write: true } },
+ { field: 'f_toggle', type: 'toggle', check: { kind: 'equal', write: true } },
+ // scalar selection
+ { field: 'f_select', type: 'select', check: { kind: 'equal', write: 'high' } },
+ { field: 'f_radio', type: 'radio', check: { kind: 'equal', write: 'yes' } },
+ // ── #2004 array headliners — these silently dropped before the fix ──
+ { field: 'f_multiselect', type: 'multiselect', check: { kind: 'setEqual', write: ['red', 'blue'] } },
+ { field: 'f_checkboxes', type: 'checkboxes', check: { kind: 'setEqual', write: ['email', 'push'] } },
+ { field: 'f_tags', type: 'tags', check: { kind: 'setEqual', write: ['alpha', 'beta', 'gamma'] } },
+ // numeric scalar — same fidelity class as rating/slider (was TEXT-affinity)
+ { field: 'f_progress', type: 'progress', check: { kind: 'equal', write: 60 } },
+ // rich text — all plain strings on the wire
+ { field: 'f_markdown', type: 'markdown', check: { kind: 'equal', write: '# Heading\n\nbody' } },
+ { field: 'f_html', type: 'html', check: { kind: 'equal', write: 'hi
' } },
+ { field: 'f_richtext', type: 'richtext', check: { kind: 'equal', write: 'rich' } },
+ { field: 'f_code', type: 'code', check: { kind: 'equal', write: '{\n "a": 1\n}' } },
+ { field: 'f_signature', type: 'signature', check: { kind: 'equal', write: 'data:image/png;base64,AAAA' } },
+ { field: 'f_qrcode', type: 'qrcode', check: { kind: 'equal', write: 'https://objectstack.ai' } },
+ // temporal — instant
+ { field: 'f_datetime', type: 'datetime', check: { kind: 'equal', write: '2024-03-15T14:30:00.000Z' } },
+ // structured JSON
+ { field: 'f_json', type: 'json', check: { kind: 'equal', write: { a: 1, b: [2, 3] } } },
+ { field: 'f_color', type: 'color', check: { kind: 'equal', write: '#FF8800' } },
+ { field: 'f_vector', type: 'vector', check: { kind: 'equal', write: [0.1, 0.2, 0.3] } },
+ // object-valued types that must store/parse as JSON, not stringify to TEXT
+ { field: 'f_record', type: 'record', check: { kind: 'equal', write: { home: '+1', work: '+2' } } },
+ { field: 'f_video', type: 'video', check: { kind: 'equal', write: { url: 'https://cdn/v.mp4', duration: 12 } } },
+ { field: 'f_audio', type: 'audio', check: { kind: 'equal', write: { url: 'https://cdn/a.mp3', duration: 30 } } },
+ { field: 'f_composite', type: 'composite', check: { kind: 'equal', write: { label: 'x', n: 1 } } },
+ { field: 'f_repeater', type: 'repeater', check: { kind: 'equal', write: [{ a: 1 }, { a: 2 }] } },
+ { field: 'f_location', type: 'location', check: { kind: 'equal', write: { lat: 37.77, lng: -122.42 } } },
+ { field: 'f_address', type: 'address', check: { kind: 'equal', write: { street: '1 Main', city: 'SF', country: 'US' } } },
+ { field: 'f_image', type: 'image', check: { kind: 'equal', write: { url: 'https://cdn/i.png', alt: 'i' } } },
+ { field: 'f_file', type: 'file', check: { kind: 'equal', write: { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 } } },
+ { field: 'f_avatar', type: 'avatar', check: { kind: 'equal', write: { url: 'https://cdn/a.png' } } },
+ // relational — store a reference id as a string and read it back verbatim.
+ // FK enforcement is off in this harness, so this asserts value fidelity
+ // (id string → id string), not referential integrity / $expand (covered
+ // elsewhere). The point here is the stored type doesn't drift.
+ { field: 'f_lookup', type: 'lookup', check: { kind: 'equal', write: 'acc_synthetic_0001' } },
+ { field: 'f_master_detail', type: 'master_detail', check: { kind: 'equal', write: 'proj_synthetic_0001' } },
+ { field: 'f_tree', type: 'tree', check: { kind: 'equal', write: 'cat_synthetic_0001' } },
+ // security — both credential types mask on read (plaintext never echoes back
+ // over HTTP). `secret` is encrypted at rest; `password` on a generic object is
+ // plaintext at rest but masked to SECRET_MASK on read (ADR-0100 / #2036 — the
+ // generic path does NOT hash it; auth owns hashing for its identity tables).
+ { field: 'f_secret', type: 'secret', check: { kind: 'masked', write: 'topsecret-value' } },
+ { field: 'f_password', type: 'password', check: { kind: 'masked', write: 'p@ssw0rd!' } },
+ // NB: f_summary (roll-up) is intentionally absent — it's a computed
+ // aggregate over related records, null on a childless fixture row; its
+ // semantics are covered by dedicated roll-up tests, not value fidelity.
+ // computed / system — not written, must materialize
+ { field: 'f_autonumber', type: 'autonumber', check: { kind: 'present' } },
+ // f_number(42) * f_percent(75) / 100 = 31.5
+ { field: 'f_formula', type: 'formula', check: { kind: 'computed', expected: 31.5 } },
+];
diff --git a/packages/rest/src/import-coerce.ts b/packages/rest/src/import-coerce.ts
index 0edf6660c6..f12204f4ba 100644
--- a/packages/rest/src/import-coerce.ts
+++ b/packages/rest/src/import-coerce.ts
@@ -32,41 +32,31 @@
*/
import type { ExportFieldMeta } from './export-format.js';
-
-/** Field types whose stored value points at another record (id). */
-const REFERENCE_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']);
-/** Single-select option types (store one option value). */
-const OPTION_TYPES = new Set(['select', 'radio']);
-/** Multi-select option types (store an array of option values). */
-const MULTI_OPTION_TYPES = new Set(['multiselect', 'checkboxes', 'tags']);
-/** Numeric field types (store a finite number). */
-const NUMBER_TYPES = new Set(['number', 'currency', 'percent', 'rating', 'slider']);
-/** Boolean field types (store a real boolean). */
-const BOOL_TYPES = new Set(['boolean', 'toggle']);
-/** Attachment field types (store a file id / url, or an array when `multiple`). */
-const FILE_TYPES = new Set(['file', 'image']);
+import {
+ SINGLE_OPTION_TYPES as OPTION_TYPES,
+ MULTI_OPTION_TYPES,
+ NUMERIC_VALUE_TYPES as NUMBER_TYPES,
+ BOOLEAN_VALUE_TYPES as BOOL_TYPES,
+ FILE_REFERENCE_TYPES as FILE_TYPES,
+ REFERENCE_VALUE_TYPES,
+ isMultiValueField as specIsMultiValueField,
+} from '@objectstack/spec/data';
/**
- * Single-value field types that become an ARRAY when flagged `multiple: true`.
- * Mirrors objectql `record-validator.ts` (`MULTI_CAPABLE_TYPES`): per the spec
- * (`field.zod.ts`), `multiple` applies to select / lookup / file / image;
- * `radio` shares the select branch and `user` is stored identically to `lookup`.
- * master_detail / reference / tree are NOT multi-capable, so a stray
- * `multiple: true` on them is ignored (they stay single — same as the engine).
+ * Field types whose stored value points at another record (id). The spec's
+ * reference class (ADR-0104 D1) plus `reference` — a legacy external-object
+ * alias that is not an authorable `FieldType` and so stays a local extra.
*/
-const MULTI_CAPABLE_TYPES = new Set(['select', 'radio', 'lookup', 'user', 'file', 'image']);
+const REFERENCE_TYPES = new Set([...REFERENCE_VALUE_TYPES, 'reference']);
/**
- * Whether a field's stored value is an array — an inherently-multi type
- * (multiselect / checkboxes / tags) or a multi-capable type flagged
- * `multiple: true`. Kept in lock-step with the engine's `isMultiValueField`
- * so a coerced cell has the SAME shape the engine will accept on insert.
+ * Whether a field's stored value is an array. Delegates to the spec's
+ * `isMultiValueField` (ADR-0104 D1) — the shared definition the engine's
+ * record-validator uses — so a coerced cell has the SAME shape the engine
+ * will accept on insert.
*/
function isMultiValueField(meta: ExportFieldMeta | undefined): boolean {
- const t = meta?.type;
- if (!t) return false;
- if (MULTI_OPTION_TYPES.has(t)) return true;
- return MULTI_CAPABLE_TYPES.has(t) && meta?.multiple === true;
+ return meta?.type ? specIsMultiValueField(meta as { type: string; multiple?: boolean }) : false;
}
/**
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index fc62fe7695..ff6c8b0ba1 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -163,6 +163,7 @@
"ALL_OPERATORS (const)",
"Address (type)",
"AddressSchema (const)",
+ "AddressValueSchema (const)",
"AggregationFunction (const)",
"AggregationMetricType (const)",
"AggregationNode (type)",
@@ -175,9 +176,15 @@
"AnalyticsQuerySchema (const)",
"ApiMethod (type)",
"AutonumberToken (type)",
+ "BOOLEAN_VALUE_TYPES (const)",
"BaseEngineOptions (type)",
"BaseEngineOptionsSchema (const)",
"BaseValidationRuleShape (interface)",
+ "CALENDAR_DATE_TYPES (const)",
+ "CLOCK_TIME_TYPES (const)",
+ "COMPUTED_VALUE_TYPES (const)",
+ "CalendarDateValueSchema (const)",
+ "ClockTimeValueSchema (const)",
"ComparisonOperatorSchema (const)",
"Compatibility (type)",
"ComputedFieldCache (type)",
@@ -306,6 +313,7 @@
"ExternalTable (type)",
"ExternalTableSchema (const)",
"FIELD_GROUP_SYSTEM_FIELDS (const)",
+ "FILE_REFERENCE_TYPES (const)",
"FILTER_OPERATORS (const)",
"FeedFilterMode (type)",
"FeedItemType (type)",
@@ -322,6 +330,7 @@
"FieldReferenceSchema (const)",
"FieldSchema (const)",
"FieldType (type)",
+ "FileLikeValueSchema (const)",
"Filter (type)",
"FilterCondition (type)",
"FilterConditionSchema (const)",
@@ -341,7 +350,9 @@
"HookEvent (const)",
"HookEventType (type)",
"HookSchema (const)",
+ "INSTANT_TYPES (const)",
"IndexSchema (const)",
+ "InstantValueSchema (const)",
"JSONValidation (type)",
"JSONValidationSchema (const)",
"JoinNode (type)",
@@ -356,13 +367,17 @@
"LifecycleSchema (const)",
"LocationCoordinates (type)",
"LocationCoordinatesSchema (const)",
+ "LocationValueSchema (const)",
"LogicalOperatorKey (type)",
"MEASURE_FIELD_TYPES (const)",
+ "MULTI_CAPABLE_TYPES (const)",
+ "MULTI_OPTION_TYPES (const)",
"Mapping (type)",
"MappingInput (type)",
"MappingSchema (const)",
"Metric (type)",
"MetricSchema (const)",
+ "NUMERIC_VALUE_TYPES (const)",
"NoSQLDataTypeMapping (type)",
"NoSQLDataTypeMappingSchema (const)",
"NoSQLDatabaseType (type)",
@@ -415,6 +430,7 @@
"QueryInput (type)",
"QuerySchema (const)",
"RECORD_SURFACE_PAGE_THRESHOLD (const)",
+ "REFERENCE_VALUE_TYPES (const)",
"RangeOperatorSchema (const)",
"RecordFlow (type)",
"RecordFlowContainer (type)",
@@ -422,6 +438,7 @@
"RecordSurface (type)",
"RecordSurfaceOptions (interface)",
"RecordSurfaceViewport (type)",
+ "ReferenceIdValueSchema (const)",
"ReferenceResolution (type)",
"ReferenceResolutionError (type)",
"ReferenceResolutionErrorSchema (const)",
@@ -436,6 +453,7 @@
"RowCrudActionOverrideInput (type)",
"RowCrudActionOverrideSchema (const)",
"RowCrudPredicates (interface)",
+ "SINGLE_OPTION_TYPES (const)",
"SQLDialect (type)",
"SQLDialectSchema (const)",
"SQLDriverConfig (type)",
@@ -444,6 +462,8 @@
"SQLiteDataTypeMappingDefaults (const)",
"SSLConfig (type)",
"SSLConfigSchema (const)",
+ "STRING_VALUE_TYPES (const)",
+ "STRUCTURED_JSON_TYPES (const)",
"Scalar (type)",
"SchemaMode (type)",
"SchemaModeSchema (const)",
@@ -493,6 +513,8 @@
"VALID_AST_OPERATORS (const)",
"ValidationRule (type)",
"ValidationRuleSchema (const)",
+ "ValueForm (type)",
+ "ValueShapeFieldDef (interface)",
"WindowFunction (const)",
"WindowFunctionNode (type)",
"WindowFunctionNodeSchema (const)",
@@ -516,6 +538,7 @@
"isDateMacroToken (function)",
"isFilterAST (function)",
"isIncoherentAggregate (function)",
+ "isMultiValueField (function)",
"isTenancyDisabled (function)",
"isTitleEligible (function)",
"missingFieldValues (function)",
@@ -531,7 +554,8 @@
"resolveDisplayField (function)",
"resolveRecordDisplayName (function)",
"sequenceWidth (function)",
- "suggestFieldType (function)"
+ "suggestFieldType (function)",
+ "valueSchemaFor (function)"
],
"./system": [
"AccessControlConfig (type)",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index d38c439937..e4f32caf2f 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -681,6 +681,7 @@
"cloud/UserReview",
"cloud/VersionRelease",
"data/Address",
+ "data/AddressValue",
"data/AggregationFunction",
"data/AggregationMetricType",
"data/AggregationNode",
@@ -689,6 +690,8 @@
"data/AnalyticsQuery",
"data/ApiMethod",
"data/BaseEngineOptions",
+ "data/CalendarDateValue",
+ "data/ClockTimeValue",
"data/ComputedFieldCache",
"data/ConditionalValidation",
"data/ConsistencyLevel",
@@ -756,6 +759,7 @@
"data/FieldNode",
"data/FieldReference",
"data/FieldType",
+ "data/FileLikeValue",
"data/FilterCondition",
"data/FormatValidation",
"data/FullTextSearch",
@@ -765,6 +769,7 @@
"data/HookContext",
"data/HookEvent",
"data/Index",
+ "data/InstantValue",
"data/JSONValidation",
"data/JoinNode",
"data/JoinStrategy",
@@ -772,6 +777,7 @@
"data/Lifecycle",
"data/LifecycleClass",
"data/LocationCoordinates",
+ "data/LocationValue",
"data/Mapping",
"data/Metric",
"data/ModeSchema",
@@ -797,6 +803,7 @@
"data/PoolConfig",
"data/Query",
"data/QueryFilter",
+ "data/ReferenceIdValue",
"data/ReferenceResolution",
"data/ReferenceResolutionError",
"data/ReplicationConfig",
diff --git a/packages/spec/src/data/field-value.test.ts b/packages/spec/src/data/field-value.test.ts
new file mode 100644
index 0000000000..ed65457c68
--- /dev/null
+++ b/packages/spec/src/data/field-value.test.ts
@@ -0,0 +1,183 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Field runtime value-shape contract (ADR-0104 D1).
+ *
+ * The write-vectors here deliberately mirror the field-zoo round-trip MATRIX
+ * (packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts) — the
+ * executable oracle of what the platform actually stores. If a case here and
+ * a MATRIX case disagree, the MATRIX (deployed reality) wins.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { FieldType } from './field.zod';
+import {
+ STRING_VALUE_TYPES,
+ NUMERIC_VALUE_TYPES,
+ BOOLEAN_VALUE_TYPES,
+ CALENDAR_DATE_TYPES,
+ INSTANT_TYPES,
+ CLOCK_TIME_TYPES,
+ SINGLE_OPTION_TYPES,
+ MULTI_OPTION_TYPES,
+ REFERENCE_VALUE_TYPES,
+ FILE_REFERENCE_TYPES,
+ STRUCTURED_JSON_TYPES,
+ COMPUTED_VALUE_TYPES,
+ MULTI_CAPABLE_TYPES,
+ isMultiValueField,
+ valueSchemaFor,
+} from './field-value.zod';
+
+const ok = (def: Parameters[0], v: unknown, form?: 'stored' | 'expanded') =>
+ expect(valueSchemaFor(def, form).safeParse(v).success).toBe(true);
+const bad = (def: Parameters[0], v: unknown, form?: 'stored' | 'expanded') =>
+ expect(valueSchemaFor(def, form).safeParse(v).success).toBe(false);
+
+describe('semantic type classes', () => {
+ it('every class member is a declared FieldType', () => {
+ const all = new Set(FieldType.options);
+ for (const cls of [
+ STRING_VALUE_TYPES, NUMERIC_VALUE_TYPES, BOOLEAN_VALUE_TYPES,
+ CALENDAR_DATE_TYPES, INSTANT_TYPES, CLOCK_TIME_TYPES,
+ SINGLE_OPTION_TYPES, MULTI_OPTION_TYPES, REFERENCE_VALUE_TYPES,
+ FILE_REFERENCE_TYPES, STRUCTURED_JSON_TYPES, COMPUTED_VALUE_TYPES,
+ MULTI_CAPABLE_TYPES,
+ ]) {
+ for (const t of cls) expect(all).toContain(t);
+ }
+ });
+
+ it('every FieldType lands in at least one value class (no unclassified types)', () => {
+ const classified = new Set([
+ ...STRING_VALUE_TYPES, ...NUMERIC_VALUE_TYPES, ...BOOLEAN_VALUE_TYPES,
+ ...CALENDAR_DATE_TYPES, ...INSTANT_TYPES, ...CLOCK_TIME_TYPES,
+ ...SINGLE_OPTION_TYPES, ...MULTI_OPTION_TYPES, ...REFERENCE_VALUE_TYPES,
+ ...FILE_REFERENCE_TYPES, ...STRUCTURED_JSON_TYPES, ...COMPUTED_VALUE_TYPES,
+ ]);
+ const unclassified = FieldType.options.filter((t) => !classified.has(t));
+ expect(unclassified).toEqual([]);
+ });
+
+ it('the shape classes are mutually disjoint (COMPUTED is the orthogonal who-writes axis: `summary` is numeric AND computed)', () => {
+ const classes = [
+ STRING_VALUE_TYPES, NUMERIC_VALUE_TYPES, BOOLEAN_VALUE_TYPES,
+ CALENDAR_DATE_TYPES, INSTANT_TYPES, CLOCK_TIME_TYPES,
+ SINGLE_OPTION_TYPES, MULTI_OPTION_TYPES, REFERENCE_VALUE_TYPES,
+ FILE_REFERENCE_TYPES, STRUCTURED_JSON_TYPES,
+ ];
+ const seen = new Map();
+ classes.forEach((cls, i) => {
+ for (const t of cls) {
+ expect(seen.has(t), `type "${t}" appears in class #${seen.get(t)} and #${i}`).toBe(false);
+ seen.set(t, i);
+ }
+ });
+ });
+});
+
+describe('isMultiValueField', () => {
+ it('inherently-multi option types are always arrays', () => {
+ for (const type of ['multiselect', 'checkboxes', 'tags']) {
+ expect(isMultiValueField({ type })).toBe(true);
+ expect(isMultiValueField({ type, multiple: false })).toBe(true);
+ }
+ });
+ it('multi-capable types require the multiple flag', () => {
+ for (const type of ['select', 'radio', 'lookup', 'user', 'file', 'image']) {
+ expect(isMultiValueField({ type })).toBe(false);
+ expect(isMultiValueField({ type, multiple: true })).toBe(true);
+ }
+ });
+ it('a stray multiple on a non-multi-capable type is ignored (matches the engine)', () => {
+ for (const type of ['master_detail', 'tree', 'text', 'number']) {
+ expect(isMultiValueField({ type, multiple: true })).toBe(false);
+ }
+ });
+});
+
+describe('valueSchemaFor — stored form (field-zoo reality)', () => {
+ it('strings', () => {
+ ok({ type: 'text' }, 'hello');
+ ok({ type: 'signature' }, 'data:image/png;base64,AAAA');
+ ok({ type: 'color' }, '#FF8800');
+ bad({ type: 'text' }, 42);
+ });
+
+ it('numerics — currency is a BARE number (the retired CurrencyValueSchema object is rejected)', () => {
+ ok({ type: 'currency' }, 1234.56);
+ bad({ type: 'currency' }, { value: 1234.56, currency: 'USD' });
+ ok({ type: 'progress' }, 60);
+ bad({ type: 'number' }, 'NaN-ish');
+ bad({ type: 'number' }, Infinity);
+ });
+
+ it('booleans are real booleans', () => {
+ ok({ type: 'boolean' }, true);
+ bad({ type: 'boolean' }, 1);
+ });
+
+ it('date is a calendar day, datetime a zoned instant, time a wall clock (#2004 / ADR-0053)', () => {
+ ok({ type: 'date' }, '2024-03-15');
+ bad({ type: 'date' }, '2024-03-15T14:30:00.000Z');
+ ok({ type: 'datetime' }, '2024-03-15T14:30:00.000Z');
+ ok({ type: 'datetime' }, '2024-03-15T14:30:00+08:00');
+ bad({ type: 'datetime' }, '2024-03-15 14:30:00');
+ bad({ type: 'datetime' }, '2024-03-15');
+ ok({ type: 'time' }, '14:30:00');
+ ok({ type: 'time' }, '14:30');
+ bad({ type: 'time' }, '14:60');
+ bad({ type: 'time' }, 'not-a-time');
+ });
+
+ it('option types enforce declared option codes; free-form without options', () => {
+ const options = [{ value: 'high' }, { value: 'low' }];
+ ok({ type: 'select', options }, 'high');
+ bad({ type: 'select', options }, 'HIGH');
+ ok({ type: 'select' }, 'anything');
+ ok({ type: 'multiselect', options }, ['high', 'low']);
+ bad({ type: 'multiselect', options }, ['high', 'nope']);
+ bad({ type: 'multiselect', options }, 'high'); // scalar at a multi field
+ ok({ type: 'tags' }, ['alpha', 'beta']);
+ });
+
+ it('references store id strings; multiple stores arrays of ids', () => {
+ ok({ type: 'lookup' }, 'acc_synthetic_0001');
+ bad({ type: 'lookup' }, { id: 'acc_1', name: 'Acme' }); // expanded form ≠ stored form
+ bad({ type: 'lookup' }, '');
+ ok({ type: 'user', multiple: true }, ['usr_1', 'usr_2']);
+ bad({ type: 'user', multiple: true }, 'usr_1');
+ });
+
+ it('expanded form admits the in-place $expand record object for references', () => {
+ ok({ type: 'lookup' }, { id: 'acc_1', name: 'Acme' }, 'expanded');
+ ok({ type: 'lookup' }, 'acc_1', 'expanded'); // unresolvable ids stay ids
+ });
+
+ it('file-likes admit the transitional inline object OR an opaque id/url string (pre-D3)', () => {
+ ok({ type: 'file' }, { url: 'https://cdn/f.pdf', name: 'f.pdf', size: 1024 });
+ ok({ type: 'image' }, { url: 'https://cdn/i.png', alt: 'i' });
+ ok({ type: 'file' }, 'file_01HXYZ');
+ ok({ type: 'image', multiple: true }, ['a.png', 'b.png']);
+ bad({ type: 'file' }, 42);
+ bad({ type: 'file' }, {});
+ });
+
+ it('structured JSON types', () => {
+ ok({ type: 'location' }, { lat: 37.77, lng: -122.42 });
+ bad({ type: 'location' }, { latitude: 37.77, longitude: -122.42 }); // the retired spec-only shape
+ ok({ type: 'address' }, { street: '1 Main', city: 'SF', country: 'US' });
+ ok({ type: 'vector' }, [0.1, 0.2, 0.3]);
+ bad({ type: 'vector' }, [0.1, 'x']);
+ ok({ type: 'repeater' }, [{ a: 1 }, { a: 2 }]);
+ bad({ type: 'repeater' }, { a: 1 });
+ ok({ type: 'record' }, { home: '+1', work: '+2' });
+ ok({ type: 'composite' }, { label: 'x', n: 1 });
+ });
+
+ it('json/code and computed types are explicitly open', () => {
+ ok({ type: 'json' }, { a: 1, b: [2, 3] });
+ ok({ type: 'formula' }, 31.5);
+ ok({ type: 'autonumber' }, 'INV-0001');
+ });
+});
diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts
new file mode 100644
index 0000000000..69f930ac6f
--- /dev/null
+++ b/packages/spec/src/data/field-value.zod.ts
@@ -0,0 +1,251 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Field runtime VALUE-shape contract (ADR-0104 D1).
+ *
+ * `FieldSchema` owns what a field *definition* looks like; this module owns
+ * what a field's runtime *value* looks like — the shape the write path
+ * accepts, drivers persist, and an unexpanded API read returns. Before this
+ * module the knowledge lived as private, hand-copied type sets in objectql's
+ * record-validator, rest's import-coerce, driver-sql, and verify; adding one
+ * multi-capable or JSON-shaped type meant updating four lists or silently
+ * corrupting data. Those consumers now derive from the classes below.
+ *
+ * Two canonical forms exist per field (ADR-0104 D1):
+ * - `stored` — the storage/wire form (e.g. lookup ⇒ record-id string,
+ * `date` ⇒ `YYYY-MM-DD`, select ⇒ option code).
+ * - `expanded` — the enriched `$expand` read form (lookup ⇒ the related
+ * record object). For types without an expansion,
+ * expanded ≡ stored.
+ *
+ * "Reality wins": where the deployed stored shape is coherent, the contract
+ * adopts it — deployed data is a wire contract we don't get to rewrite by
+ * editing Zod. This is why `currency` is a bare number (not the never-consumed
+ * `CurrencyValueSchema` object) and `location` is `{lat, lng}` (what field-zoo
+ * stores), not the never-consumed `{latitude, longitude}` shape.
+ *
+ * Purity: schemas/constants/derivation only — no runtime logic, no caching
+ * (Prime Directive #2). Consumers cache `valueSchemaFor` results per field
+ * definition; building a Zod schema per write is the one performance trap
+ * this contract has (ADR-0104 performance budget).
+ */
+
+import { z } from 'zod';
+import { lazySchema } from '../shared/lazy-schema';
+import type { FieldType } from './field.zod';
+import { AddressSchema } from './field.zod';
+
+/* ────────────────────────────────────────────────────────────────────────────
+ * Semantic type classes
+ *
+ * Membership is over `FieldType` values only. Driver-internal aliases that are
+ * NOT authorable field types (`integer`, `int`, `float`, `object`, `array`,
+ * external `reference`) stay in their drivers, layered on top of these sets.
+ * ──────────────────────────────────────────────────────────────────────────── */
+
+/** Value is a plain string (validated per-type: email/url/phone formats, lengths). */
+export const STRING_VALUE_TYPES: ReadonlySet = new Set([
+ 'text', 'textarea', 'email', 'url', 'phone', 'password', 'secret',
+ 'markdown', 'html', 'richtext', 'code',
+ 'color', 'signature', 'qrcode',
+] as const satisfies readonly FieldType[]);
+
+/** Value is a finite numeric scalar. `currency` IS a bare number (see header). */
+export const NUMERIC_VALUE_TYPES: ReadonlySet = new Set([
+ 'number', 'currency', 'percent', 'rating', 'slider', 'progress', 'summary',
+] as const satisfies readonly FieldType[]);
+
+/** Value is a JS boolean on the wire (driver read-coercion repairs SQL 0/1). */
+export const BOOLEAN_VALUE_TYPES: ReadonlySet = new Set([
+ 'boolean', 'toggle',
+] as const satisfies readonly FieldType[]);
+
+/** Naive calendar day, stored `YYYY-MM-DD` — NOT an instant (ADR-0053). */
+export const CALENDAR_DATE_TYPES: ReadonlySet = new Set([
+ 'date',
+] as const satisfies readonly FieldType[]);
+
+/** UTC instant, stored as ISO-8601 with explicit zone (ADR-0053). */
+export const INSTANT_TYPES: ReadonlySet = new Set([
+ 'datetime',
+] as const satisfies readonly FieldType[]);
+
+/** Wall-clock time of day, `HH:MM[:SS[.fff]]` (+ optional zone) — not `Date.parse`-able (#2004). */
+export const CLOCK_TIME_TYPES: ReadonlySet = new Set([
+ 'time',
+] as const satisfies readonly FieldType[]);
+
+/** Single-choice option types: value is one declared option code. */
+export const SINGLE_OPTION_TYPES: ReadonlySet = new Set([
+ 'select', 'radio',
+] as const satisfies readonly FieldType[]);
+
+/** Inherently-multi option types: value is an array of option codes (tags: free-form). */
+export const MULTI_OPTION_TYPES: ReadonlySet = new Set([
+ 'multiselect', 'checkboxes', 'tags',
+] as const satisfies readonly FieldType[]);
+
+/**
+ * Value points at another record: a record-id string in stored form, the
+ * related record object in expanded form (`$expand` overwrites in place).
+ */
+export const REFERENCE_VALUE_TYPES: ReadonlySet = new Set([
+ 'lookup', 'master_detail', 'user', 'tree',
+] as const satisfies readonly FieldType[]);
+
+/**
+ * Media/attachment types. Stored form TODAY is the legacy inline metadata
+ * object (`{url, name?, size?, ...}`) or an opaque file-id/url string;
+ * ADR-0104 D3 (file-as-reference) narrows this to a `sys_file` id. The stored
+ * schema below deliberately admits both until D3 lands.
+ */
+export const FILE_REFERENCE_TYPES: ReadonlySet = new Set([
+ 'image', 'file', 'avatar', 'video', 'audio',
+] as const satisfies readonly FieldType[]);
+
+/** Structured JSON payloads persisted in JSON columns. */
+export const STRUCTURED_JSON_TYPES: ReadonlySet = new Set([
+ 'json', 'composite', 'repeater', 'record', 'location', 'address', 'vector',
+] as const satisfies readonly FieldType[]);
+
+/** Server-computed types: never client-written; shape is producer-owned. */
+export const COMPUTED_VALUE_TYPES: ReadonlySet = new Set([
+ 'formula', 'summary', 'autonumber',
+] as const satisfies readonly FieldType[]);
+
+/**
+ * Single-value types that become an ARRAY when flagged `multiple: true`
+ * (`FieldSchema.multiple`: select/lookup/file/image; `radio` shares the select
+ * branch; `user` stores identically to `lookup`). Previously hand-copied in
+ * objectql record-validator AND rest import-coerce.
+ */
+export const MULTI_CAPABLE_TYPES: ReadonlySet = new Set([
+ 'select', 'radio', 'lookup', 'user', 'file', 'image',
+] as const satisfies readonly FieldType[]);
+
+/**
+ * The minimal slice of a field definition the value contract reads. Structural
+ * (not `Field`) so runtime callers with their own trimmed field-def interfaces
+ * (objectql's `FieldDef`, rest's `ExportFieldMeta`) can pass theirs verbatim.
+ */
+export interface ValueShapeFieldDef {
+ type: string;
+ multiple?: boolean;
+ options?: Array<{ value: string | number } | string | number>;
+}
+
+/**
+ * Whether a field's persisted value is an array — an inherently-multi option
+ * type, or a multi-capable type flagged `multiple: true`. THE shared
+ * definition (was duplicated verbatim in record-validator + import-coerce).
+ */
+export function isMultiValueField(def: ValueShapeFieldDef): boolean {
+ if (MULTI_OPTION_TYPES.has(def.type)) return true;
+ return MULTI_CAPABLE_TYPES.has(def.type) && def.multiple === true;
+}
+
+/* ────────────────────────────────────────────────────────────────────────────
+ * Canonical value schemas
+ * ──────────────────────────────────────────────────────────────────────────── */
+
+/** `YYYY-MM-DD` — the calendar-day stored form (driver collapses Date → day). */
+export const CalendarDateValueSchema = lazySchema(() =>
+ z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'expected YYYY-MM-DD (calendar day, not an instant)'));
+
+/** ISO-8601 instant with explicit zone — the `datetime` stored form. */
+export const InstantValueSchema = lazySchema(() =>
+ z.string().refine((s) => !Number.isNaN(Date.parse(s)) && (/[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s.slice(10))),
+ 'expected an ISO-8601 instant with explicit zone (e.g. 2026-03-15T14:30:00.000Z)'));
+
+/** `HH:MM[:SS[.fff]]` with optional zone — the `time` stored form (#2004). */
+export const ClockTimeValueSchema = lazySchema(() =>
+ z.string().regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]([01]\d|2[0-3]):?[0-5]\d)?$/,
+ 'expected HH:MM or HH:MM:SS (wall-clock time of day)'));
+
+/** GPS point — the shape field-zoo stores and renderers read. See header re: the retired `{latitude, longitude}` form. */
+export const LocationValueSchema = lazySchema(() => z.object({
+ lat: z.number().min(-90).max(90).describe('Latitude'),
+ lng: z.number().min(-180).max(180).describe('Longitude'),
+ altitude: z.number().optional().describe('Altitude in meters'),
+ accuracy: z.number().optional().describe('Accuracy in meters'),
+}));
+
+/** Structured address value — adopts the (previously unconsumed) `AddressSchema` as the enforced contract. */
+export const AddressValueSchema = AddressSchema;
+
+/**
+ * Media/attachment stored value — TRANSITIONAL (pre-D3): either an opaque
+ * file-id/url string, or the legacy inline metadata object. ADR-0104 D3
+ * narrows this to a `sys_file` id string; new writers should prefer the
+ * string form now.
+ */
+export const FileLikeValueSchema = lazySchema(() => z.union([
+ z.string().min(1),
+ z.looseObject({
+ url: z.string().optional(),
+ name: z.string().optional(),
+ size: z.number().optional(),
+ alt: z.string().optional(),
+ duration: z.number().optional(),
+ }).refine((o) => Object.keys(o).length > 0, 'empty file value'),
+]));
+
+/** Record-id string — the stored form of every reference type. */
+export const ReferenceIdValueSchema = lazySchema(() => z.string().min(1));
+
+function optionCodes(def: ValueShapeFieldDef): string[] {
+ if (!Array.isArray(def.options)) return [];
+ return def.options.map((o) => (typeof o === 'object' && o !== null ? String(o.value) : String(o)));
+}
+
+export type ValueForm = 'stored' | 'expanded';
+
+/**
+ * The runtime value schema for one field definition. Pure derivation — no
+ * caching here; runtime consumers MUST cache per field definition (building a
+ * `z.object` per write is an order of magnitude costlier than parsing).
+ *
+ * The schema describes a PRESENT value: null/undefined/required handling stays
+ * with the caller (insert vs PATCH semantics differ — see record-validator).
+ * Where the contract is deliberately open (`json`, `code` payloads, computed
+ * types), the schema is `z.unknown()` — openness is now an explicit decision,
+ * not an accident of nobody checking.
+ */
+export function valueSchemaFor(def: ValueShapeFieldDef, form: ValueForm = 'stored'): z.ZodType {
+ const t = def.type;
+
+ const element = ((): z.ZodType => {
+ if (STRING_VALUE_TYPES.has(t)) return z.string();
+ if (NUMERIC_VALUE_TYPES.has(t)) return z.number().finite();
+ if (BOOLEAN_VALUE_TYPES.has(t)) return z.boolean();
+ if (CALENDAR_DATE_TYPES.has(t)) return CalendarDateValueSchema;
+ if (INSTANT_TYPES.has(t)) return InstantValueSchema;
+ if (CLOCK_TIME_TYPES.has(t)) return ClockTimeValueSchema;
+ if (SINGLE_OPTION_TYPES.has(t) || MULTI_OPTION_TYPES.has(t)) {
+ // tags (and option types authored without options) are free-form strings.
+ const codes = optionCodes(def);
+ return codes.length > 0 ? z.enum(codes as [string, ...string[]]) : z.string();
+ }
+ if (REFERENCE_VALUE_TYPES.has(t)) {
+ // Expanded form: `$expand` replaces the id in place with the related
+ // record object (objectql engine `expandRelatedRecords`). The record's
+ // own shape is that object's contract, not this field's — hence open.
+ return form === 'expanded'
+ ? z.union([ReferenceIdValueSchema, z.record(z.string(), z.unknown())])
+ : ReferenceIdValueSchema;
+ }
+ if (FILE_REFERENCE_TYPES.has(t)) return FileLikeValueSchema;
+ if (t === 'location') return LocationValueSchema;
+ if (t === 'address') return AddressValueSchema;
+ if (t === 'composite') return z.record(z.string(), z.unknown());
+ if (t === 'record') return z.record(z.string(), z.unknown());
+ if (t === 'repeater') return z.array(z.record(z.string(), z.unknown()));
+ if (t === 'vector') return z.array(z.number());
+ // `json` payloads, computed outputs not covered by a shape class above
+ // (`formula` / `autonumber` — producer-owned), and any future type default
+ // to explicitly-open. Openness is a decision here, not an accident.
+ return z.unknown();
+ })();
+
+ return isMultiValueField(def) ? z.array(element) : element;
+}
diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts
index 5a19a34663..477bfa0eb7 100644
--- a/packages/spec/src/data/field.zod.ts
+++ b/packages/spec/src/data/field.zod.ts
@@ -117,6 +117,12 @@ export const SelectOptionSchema = lazySchema(() => z.object({
/**
* Location Coordinates Schema
* GPS coordinates for location field type
+ *
+ * @deprecated Never consumed by the runtime, and its key names contradict what
+ * the platform actually stores: a `location` value is `{lat, lng}` (see the
+ * field-zoo round-trip oracle), not `{latitude, longitude}`. Use
+ * `LocationValueSchema` / `valueSchemaFor` from `field-value.zod.ts`
+ * (ADR-0104 D1). Removal rides the next spec major.
*/
export const LocationCoordinatesSchema = lazySchema(() => z.object({
latitude: z.number().min(-90).max(90).describe('Latitude coordinate'),
@@ -144,9 +150,16 @@ export const CurrencyConfigSchema = lazySchema(() => z.object({
/**
* Currency Value Schema
* Runtime value structure for currency fields
- *
+ *
* Note: Currency codes are validated by length only (3 characters) to support flexibility.
* See CurrencyConfigSchema for details on currency code validation strategy.
+ *
+ * @deprecated This shape was never consumed and contradicts the actual runtime
+ * contract: a `currency` field's value is a BARE NUMBER everywhere (validator,
+ * SQL driver `float` column, import coercion, field-zoo oracle); the currency
+ * code lives in field config (`CurrencyConfigSchema`), not per value. Use
+ * `valueSchemaFor` from `field-value.zod.ts` (ADR-0104 D1). Removal rides the
+ * next spec major.
*/
export const CurrencyValueSchema = lazySchema(() => z.object({
value: z.number().describe('Monetary amount'),
diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts
index e2d49b5ea6..b43ea0c6ed 100644
--- a/packages/spec/src/data/index.ts
+++ b/packages/spec/src/data/index.ts
@@ -5,6 +5,8 @@ export * from './filter.zod';
export * from './date-macros.zod';
export * from './object.zod';
export * from './field.zod';
+// Field runtime value-shape contract (ADR-0104 D1)
+export * from './field-value.zod';
export * from './autonumber-format';
export * from './validation.zod';
export * from './hook.zod';