Skip to content

Commit 041c8cf

Browse files
fix(spec): describe format by the readers that exist, not by email/phone (#19763)
Fixes #19679 Clause-②: no One published sentence described `FieldSchema.format` as `Format string (e.g. email, phone)`: two example words, with no field type and no reader named. It shipped verbatim into `packages/spec/json-schema/`, into `dist`, into the published `src/**/*.zod.ts`, and to a customer in `content/docs/references/data/field.mdx`. On an `autonumber` field the same key is the record-number pattern, so following the documented example there minted `email1`. This round fixes the sentence and nothing else. The key is still `z.string().optional()`: nothing is split, narrowed, retired or gated by type, and no consumer is touched. ## What reads the key, measured and stated positively Round 2 rewrote this section. Round 1 listed two readers and a `nothing else` row. The at-tier review found a third reader at the pinned objectui, so this table lists what each measured reader does and makes no claim that the list is complete. | reader | field types | what the value means there | |:--|:--|:--| | `resolveAutonumberFormat` (`packages/spec/src/data/autonumber-format.ts:196`), minted through by `packages/objectql/src/engine.ts` `applyAutonumbers` (`:5051`) and twice by `packages/drivers/driver-sql/src/sql-driver.ts` (`:10639`, `:10744`) | `autonumber` | the record-number **pattern**: canonical `autonumberFormat` first, then this key, then the default `{0000}` | | `lintAutonumberFormats` (`packages/lint/src/lint-autonumber-formats.ts:60-62`), wired into `os lint`, `os compile` and `os validate` through `packages/lint/src/authoring-rules.ts` | `autonumber` | the same pattern, linted at build time for unrecognised `{...}` tokens and bad `{field}` references | | objectui `DateCellRenderer` and `DateTimeCellRenderer` (`packages/fields/src/index.tsx:1127`, `:1176`) at the pinned `.objectui-sha` `87af769e9a3ee28ace099fdd653d3ebd79fe82e2` | `date`, `datetime` | a display **style**, with its own words and a different default per type | | objectui `resolveCellRendererType` (`packages/fields/src/index.tsx:2915-2947`), same pin; called from the grid (`plugin-grid/src/cellRendererResolution.ts:114`), the detail views (`DetailSection.tsx:411`, `DetailView.tsx:1298`, `HeaderHighlight.tsx:137`, `RelatedList.tsx:1282`), and the kanban, gallery, report and dashboard views | textual base types (`text`, `textarea` and the rest of `TEXTUAL_BASE_TYPES`) | a cell-renderer **hint**: a small word set promotes the cell to a richer renderer. Pinned end to end by `packages/plugin-grid/src/__tests__/formatHintedColumnRenderer-8920.test.tsx`, where `{ type: 'text', format: 'phone' }` renders a `tel:` anchor | | objectui `renderFieldValue` (`packages/plugin-dashboard/src/recordFields.tsx:382-427`), same pin, fed the object field's `format` at `:334` | any | a display **pattern**: a leading currency symbol, a `%`, or any of the letters `Y`, `M`, `D`, `H`, `m`, `s` selects a formatter, and anything else falls through to `resolveCellRendererType` | Run against this branch's build of `@objectstack/spec` (`dist/data/index.mjs`), seq 1: ```text format: 'INV-{0000}' -> INV-0001 format: 'email' -> email1 { autonumberFormat: 'A-{000}', format: 'email' } -> A-001 {} -> 0001 ``` So an author who followed the key's own documentation onto an `autonumber` field got `email1` as a business identifier. It parsed, it stored, and neither the runtime nor the build-time lint reported it: `lintAutonumberFormats` returns no finding for `format: 'email'`, while a `{nope}{000}` control draws `autonumber-references-unknown-field`. **The negatives the description keeps, each with its radius:** - *On any non-`autonumber` field the server does not act on the key.* Radius: this repo's `packages/**` non-test sources at `a8c7f17751`. Instrument: `git grep` over every non-call property read of `format` (107 hits). Triaged, the only field-definition reads are `autonumber-format.ts:199` and `lint-autonumber-formats.ts:62`, both on the autonumber arm. A word census over `packages/drivers` and `packages/objectql/src` finds every field-reading line gated on the autonumber type (`sql-driver.ts:10638`, `:10743`; `engine.ts:5044`). Firing control: the same instrument returns the two known readers. Named blind spots: multi-line destructuring, computed keys, and whole-object forwarding. Corroboration: `driver-sql`'s own `FIELD_KEY_STORAGE_CLASS` classifies `format` as `presentation` (`packages/drivers/driver-sql/src/builtin-column-collision.ts:97`). - *The write-time record validator never reads it.* Radius: `packages/objectql/src/validation/record-validator.ts`. `def.format` has 0 reads, against `def.type` 7 and `def.maxLength` 2 as lit controls. `const t = def.type` is at `:628`, and the email, url and phone checks at `:746`, `:749` and `:752` test `t`. - *The spec checks nothing but that it is a string.* 49 `FieldType` members times 6 values (`email`, `phone`, `url`, `relative`, an arbitrary string, `INV-{0000}`) gives 294 cells, run through both `FieldSchema` and `ObjectSchema`: 0 issues on a `format` path. Firing control: `format: 123` draws 1 issue on each. - *The two named objectui arms fall back silently.* Read at the pin: the date cell hands the word to `formatDate` (`packages/core/src/utils/date-display.ts:198`), which honours `short` and `relative` and otherwise paints its default face. The datetime cell handles `relative`, `short` and `compact` and hands any other word to `formatDateTime`, which paints its default face (`index.tsx:1243-1282`). `resolveCellRendererType` returns the base type's renderer for a word outside its map (`:2943-2946`). None of the three warns or throws. **Where a value check lives**, which the description now states: the record validator's email, url and phone checks key on the field **type** (`type: 'email'`), and the closed `email | url | phone | json` vocabulary belongs to a **`format` validation rule** (`FormatValidationSchema`, `packages/spec/src/data/validation.zod.ts:204`, its `format` key at `:214`). The description no longer says `email`, `url` and `phone` are "not formats". On a plain-text field objectui reads them as renderer hints, and whether that reading should become a declared vocabulary is the decision this card leaves open. ## Write surface `packages/spec/src/data/field.zod.ts` — the one `describe` string. Everything else in the diff is the repo's own generators: `pnpm --filter @objectstack/spec gen:docs` rewrote `content/docs/references/data/field.mdx`, `data/object.mdx` and `system/migration.mdx` (three projections of one string). Nothing was hand-edited under `content/docs/references/`. **The conditional fence did not trip.** `packages/spec/authorable-surface/data.json` is held by open PR #19618. `check:authorable-surface` ran green across the edit and the file did not move — a description is not an authorable key — so no hunk under another claim was touched. ## Changeset — measured, not assumed `patch` on `@objectstack/spec`. `skip-changeset` would have been wrong, and the measurement is the reason rather than a rule of thumb: - `packages/spec/src/data/field.zod.ts` is itself a published file — it matches `src/**/*.zod.ts` in this package's `files[]`. - Greping a distinctive fragment of the new text (`record-number PATTERN`) over each `files[]` entry: **22** `dist/` files and **13** `json-schema/` files carry it. - Positive control from the same source — `required`'s existing published describe (`Write-time contract (ADR-0113)`) — returns **22** and **13** over the same two trees. Same counts, so the route is measuring what it claims to measure. ## Is a regression test owed? No, and here is the reasoning rather than a silence - **A negative pin is impossible here.** The obvious pin — assert the description never says `email` or `phone` — goes red on the *correction*, because the new sentence deliberately names both words in order to redirect the author to the field `type` and the validation rule. The defect was a false sentence, not the presence of two words. - **A positive pin on this prose would rot by design.** The next honest sharpening of the sentence breaks it, so the next author edits or deletes the pin — a check nobody trusts is worse than none, and this lane's rule prefers deleting the construct that permits the error over adding a check. - **The construct that permits the error is out of scope this round.** It is `z.string()` with no declared vocabulary and no type gating — narrowing it is exactly the contract-shape decision triage reserved. - **Every behaviour claim the new sentence makes is pinned, but not all of it in this repository.** The autonumber claims are pinned here by `packages/spec/src/data/autonumber-format.test.ts`: canonical-over-shorthand precedence, the `{0000}` default rendering as `0001`, and the no-slot branch. The two objectui arms are pinned in objectui at the pin: `packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx` for the date and datetime styles, and `packages/plugin-grid/src/__tests__/formatHintedColumnRenderer-8920.test.tsx` for the plain-text hint. The description names those arms without copying their words, so an objectui change to a word list cannot make the spec sentence false. An objectui change that removes an arm could, and nothing in this repository would go red. That residual risk is why the arms are introduced as examples. - What *would* earn its place is a gate asserting that every example value a `describe` offers is honoured by some reader. Nothing like it exists, building it is well outside a one-sentence repair, and it belongs with the decision below. ## Verification Final commit `a8c7f17751`. No merge of `origin/main` this round: `origin/main` is one commit ahead (`ed4b655e5b`, `scripts/pm/**` only) and touches none of this PR's five files, so `os-regen-merge.sh` was not needed. The branch still carries round 1's merge of `dc1b98680b`. | check | result at `a8c7f17751` | |:--|:--| | `pnpm --filter @objectstack/spec build` (under `scripts/pm/os-verify-lock.sh`) | `VERDICT command-exit 0` | | `pnpm --filter @objectstack/spec typecheck`, then `test`, in one locked run | `VERDICT command-exit 0`; 516 test files, 15056 tests passed, 1 todo | | `gen:schema`, then `gen:docs` | rewrote exactly `data/field.mdx` (1 row), `data/object.mdx` (2 rows) and `system/migration.mdx` (2 rows) | | `pnpm --filter @objectstack/spec check:docs` | exit 0: `225 generated files in sync with packages/spec` | | `pnpm --filter @objectstack/spec check:generated` | exit 0: `All 15 generated artifacts are up to date` | | sentence census | the new sentence appears in `field.mdx` 1 time, `object.mdx` 2 times and `migration.mdx` 2 times; the round-1 sentence and the original sentence appear 0 times in each. `git diff dc1b986 HEAD -- content/docs/references` is the five `format` rows and nothing else | | every family `scripts/pm/dispatch-gates.mjs` derives for this path set, run from its own `--commands` list with each exit recorded | `--ran`: 101 derived, 98 run at exit 0, 3 NOT MEASURED, 0 unrun | | eslint, narrowed | 1 file linted (`field.zod.ts`): 0 errors, 0 warnings, read from `--format json`. The `.mdx` pages and the `.changeset/*.md` answer `File ignored because no matching configuration was supplied`. `eslint.config.mjs:327-329` records that type-aware linting is enabled for no file, so this diff cannot move the verdict on an untouched file | **NOT MEASURED, declared to CI:** `check:skill-examples`, `check:dual-build-cjs-loads` and `check:lean-entry-closure` exited 3 (`PREREQUISITE NOT MET`). Each reads built output of packages this diff does not touch: `client-react`, the whole workspace, and `objectql`. Not a pass and not a finding. **The red at `66cb68d947` was an intermediate head.** That push carried the new `describe` before its regenerated pages. `875c198d2c` added them, but its run was superseded by the next push before the docs-sync step ran (`Type Check · source gates` cancelled, step 26 `Check generated reference docs are in sync with the spec` skipped), so that head is **not measured**. At the current head `a8c7f17751` the same step reads `success`. ## Acceptance notes **1. The key-vocabulary decision card triage said was owed does not exist yet.** Triage graded this card "scoped to the `describe`" and said the wider problem "belongs in the decision box as its own card" — nothing has been filed, and this round deliberately does not file it. The readings below are recorded so whoever files it starts from measurements instead of from scratch. The shape of the decision: **one `z.string()` key is read by at least four readers with four different vocabularies (the autonumber pattern, the date and datetime style, the textual renderer hint, and the dashboard display pattern), with no declared value set and nothing that checks the readers agree.** - **Autonumber arm.** `resolveAutonumberFormat` (`packages/spec/src/data/autonumber-format.ts:196`); `AutonumberFormatSource` declares `format` as the shorthand predating `autonumberFormat`; call sites `packages/objectql/src/engine.ts` `applyAutonumbers` and `packages/drivers/driver-sql/src/sql-driver.ts` (two). A pattern-less value renders as literal text plus the bare counter — measured `email1` above — and nothing refuses it. - **Display-style arm** (objectui, pinned `.objectui-sha` `87af769e9a3ee28ace099fdd653d3ebd79fe82e2`): `packages/fields/src/index.tsx:1127` `const style = dateField.format || 'relative';` for the `date` cell, and `:1176` `const authoredFormat = (field as DateTimeFieldMetadata | undefined)?.format || 'compact';` for the `datetime` cell. Two different defaults for one key. The vocabularies differ too: `formatDate` (`packages/core/src/utils/date-display.ts`) honours `short` and `relative` and silently paints its default locale face for anything else, while the datetime cell maps `relative` and `short` onto its own faces and falls through for everything else, `compact` and date patterns such as `YYYY-MM-DD` included. - **Renderer-hint and display-pattern arms** (objectui, same pin), found by round 2's at-tier review and census. `resolveCellRendererType` promotes a textual field by a word set (`packages/fields/src/index.tsx:2915-2946`), and `plugin-dashboard`'s `renderFieldValue` reads the same key as a display pattern (`recordFields.tsx:382-427`). The two disagree on at least one word. `format: 'email'` on a `text` field is a `mailto:` hint to the resolver, but the dashboard's pattern test matches its `m` and hands the value to `formatDate`, which answers an em dash. That defect is reported separately in the round-2 report. - **Blast radius today is documentation, not data.** No field anywhere in this repository authors `format` — measured over `packages/*/src/objects` and `examples/*/src/data`. ⚠️ The published `skills/` corpus is different: it teaches the key on the autonumber arm. `skills/objectstack-data/rules/field-types.md` spells `{ type: 'autonumber', format: 'CASE-{0000}' }` under its Autonumber heading and uses `format:` in both `order_no` examples. That is the shorthand this describe now tells a new field to replace with `autonumberFormat`, so the skill and the describe disagree on which spelling to teach. That disagreement is input for the key-vocabulary decision below, ⛔ not a change made here. What the false sentence reached was the reference docs and the JSON Schema, i.e. exactly what an AI authoring agent reads before writing a field. - **The options the card will have to weigh** are all surface-moving and all reserved: split the key (add a `displayFormat`, or promote the style arm), retire one arm under ADR-0049 enforce-or-remove, narrow the type to a declared vocabulary, or declare the two-arm shape and make each arm refuse the other's words loudly. Each widens or removes a published surface. - Dedupe words for whoever files it: `format`, `autonumberFormat`, `displayFormat`, `field vocabulary`, `date display style`. **2. `packages/spec/liveness/field.json`'s `format` row understates the key.** It is a bare `{ "status": "live", "evidence": "packages/objectql/src/engine.ts" }` — no `verifiedAt`, no function anchor and no note, and it records only the autonumber reader. Its own siblings are richer on exactly the two axes it is missing: `autonumberFormat` names the consuming function, and `rows` carries `evidenceScope: "cross-repo"` for a key objectui reads. The objectui display-style arm is invisible in this row. Not touched here — a ledger row is not the `describe` this card scopes — and it is work the decision card above has to redo anyway, so it is recorded rather than filed. **The same false meaning on six hand-written doc rows — filed as #19764, ⛔ not fixed here.** This PR repairs the key's own `describe`. Six hand-written rows in `content/docs/data-modeling/field-types.mdx` (`:24` under `text`, `:71` under `phone`) and `content/docs/data-modeling/validation-rules.mdx` (`:46` `text`, `:64` `email`, `:72` `url`, `:80` `phone`) credit `format` with validation it never performs. The reader, measured at source: `packages/objectql/src/validation/record-validator.ts:628` binds `const t = def.type`, and the three shape checks at `:746` / `:749` / `:752` are `t === 'email'` / `'url'` / `'phone'`; that file reads `def.format` **0** times against a same-file `def.type` lit control of **7**. ⇒ on **validation**, this PR's `describe`, which sends an author to `type` and to the `format` validation rule, is the correct side. On display it is not the whole story: the `text` row (`field-types.mdx:24`) describes a live renderer hint at the pinned objectui (see the reader table), which the seat recorded on #19764. Three of them also declare a `Default` of `email` / `url` / `phone` that does not exist: `FieldSchema.parse({ name: 'x', type: 'email' })` returns no `format` key at all. Out of this PR's scope — triage scoped the card to the `describe`, and hand-written docs are outside its claimed file surface — so they ride their own card, where the routing question (hand-written `content/docs/**` versus the `FieldSchema` surface it describes) is triage's. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2005a55 commit 041c8cf

5 files changed

Lines changed: 28 additions & 6 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
**Fix:** `FieldSchema.format`'s description said only `Format string (e.g. email, phone)`. It offered two example words without saying which field type they apply to or what reads them, and it shipped in the JSON Schema, in `dist`, in the published `src/**/*.zod.ts` and in `content/docs/references/data/field.mdx`. Followed onto an `autonumber` field, it produced `email1` as a business identifier. The value parsed, it was stored, and nothing reported it.
6+
7+
`Clause-②: no`: the key is still `z.string().optional()`. Nothing is split, narrowed, retired or gated by type. No accept set moves in either direction, and no consumer is touched. Only the sentence changes.
8+
9+
**What the description now says, reader by reader.** Each point was measured, not recalled, and each is stated as what a reader does rather than as a claim that nothing else reads the key.
10+
11+
- On an `autonumber` field the key is the record-number **pattern**, the shorthand that predates `autonumberFormat`. `resolveAutonumberFormat` takes the canonical key first, then this one, then the declared default `{0000}`. The ObjectQL engine's `applyAutonumbers` and `driver-sql` both mint through it, and the build-time autonumber lint in `@objectstack/lint` reads the same pattern. Measured against this build: `format: 'INV-{0000}'` gives `INV-0001`; `format: 'email'` gives `email1`, because a value with no `{...}` token is literal text with the bare counter appended; `{ autonumberFormat: 'A-{000}', format: 'email' }` gives `A-001`.
12+
- On any other field type the server does not act on the key. It picks no column type from it, coerces no value by it and runs no check from it. The write-time record validator's built-in email, url and phone checks key on the field `type`.
13+
- The Studio UI reads the key as a display hint, using words and defaults that its renderers own. The description names two examples. The `date` and `datetime` cells read a display style. On a plain-text field, the shared cell-renderer resolver reads a small word set that promotes the cell to a richer renderer; at the pinned objectui, `{ type: 'text', format: 'phone' }` renders a `tel:` link. The words themselves are deliberately not copied into the spec. They belong to those renderers, and a copy here would go stale without anything going red.
14+
- The spec declares no vocabulary for the key and checks nothing except that it is a string, so any string parses on any field type.
15+
16+
To constrain a **value**, the description points to the field `type` or to a `format` validation rule (`{ type: 'format', field, format: 'email' }`), whose own `format` key is the closed set `email | url | phone | json`.
17+
18+
The wider question is deliberately left alone here. One `z.string()` key is read differently by different readers, and nothing checks that they agree. Whether any of those readings should become a declared vocabulary is a contract-shape decision.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const result = CurrencyConfigSchema.parse(data);
5656
| **label** | `string` | optional | Human readable label |
5757
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | ✅ | Field Data Type |
5858
| **description** | `string` | optional | Tooltip/Help text |
59-
| **format** | `string` | optional | Format string (e.g. email, phone) |
59+
| **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. |
6060
| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
6161
| **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
6262
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ const result = ApiMethod.parse(data);
218218
| **label** | `string` | optional | Human readable label |
219219
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type |
220220
| **description** | `string` | optional | Tooltip/Help text |
221-
| **format** | `string` | optional | Format string (e.g. email, phone) |
221+
| **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. |
222222
| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
223223
| **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
224224
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |
@@ -551,7 +551,7 @@ const result = ApiMethod.parse(data);
551551
| **label** | `string` | optional | Human readable label |
552552
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type |
553553
| **description** | `string` | optional | Tooltip/Help text |
554-
| **format** | `string` | optional | Format string (e.g. email, phone) |
554+
| **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. |
555555
| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
556556
| **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
557557
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |

‎content/docs/references/system/migration.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ Add a new field to an existing object
5656
| **label** | `string` | optional | Human readable label |
5757
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type |
5858
| **description** | `string` | optional | Tooltip/Help text |
59-
| **format** | `string` | optional | Format string (e.g. email, phone) |
59+
| **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. |
6060
| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
6161
| **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
6262
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |
@@ -476,7 +476,7 @@ Add a new field to an existing object
476476
| **label** | `string` | optional | Human readable label |
477477
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type |
478478
| **description** | `string` | optional | Tooltip/Help text |
479-
| **format** | `string` | optional | Format string (e.g. email, phone) |
479+
| **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. |
480480
| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
481481
| **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
482482
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |

‎packages/spec/src/data/field.zod.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1087,7 +1087,11 @@ export const FieldSchema = lazySchema(() => {
10871087
label: z.string().optional().describe('Human readable label'),
10881088
type: FieldType.describe('Field Data Type'),
10891089
description: z.string().optional().describe('Tooltip/Help text'),
1090-
format: z.string().optional().describe('Format string (e.g. email, phone)'),
1090+
format: z.string().optional().describe('Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. '
1091+
+ 'On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: \'INV-{0000}\'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: \'email\'` mints `email1`). Prefer `autonumberFormat` on a new field. '
1092+
+ 'On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. '
1093+
+ 'The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. '
1094+
+ 'To constrain a VALUE, use the field `type` (the write-time record validator\'s built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` | `url` | `phone` | `json`.'),
10911095

10921096
// `columnName` removed in the 16.x line (#2377, ADR-0049): the SQL driver
10931097
// hardcodes the physical column = field key (createColumn never reads it), so

0 commit comments

Comments
 (0)