Skip to content

Commit 51290bc

Browse files
fix(spec): author-facing describes and refusals drop service-interface names, ruling dates and foreign example ids (#22125)
Part of #22093 Clause-②: no Wording only: no schema, key, type, export or error-code change. Patch changeset for `@objectstack/spec`, `@objectstack/service-automation` and `@objectstack/platform-objects`. objectui#11785 is the Studio half (splitting an author message from the detail); this PR changes the server-side strings only. The card keeps two named remainders, listed under "Remainder of the card and its route" below. ## What changed Form help and refusals that an author reads no longer carry service-interface names (`IXxxService.method()`), ruling dates ("maintainer ruling DATE", "ruled DATE") or another product's example ids. The rationale they carried moves into the code comment beside each string. Each describe and refusal still states the rule, why it exists and the repair. ## How "author-facing" was measured (instrument and radius) Studio reaches these strings through five routes. I measured each one at `a543e244f`: | Route | What Studio renders | Instrument | | :--- | :--- | :--- | | R1 | `/meta/types` `schema`: every `DEFAULT_METADATA_TYPE_REGISTRY` type, derived as `metadata-protocol` derives it (`z.toJSONSchema`, `unrepresentable: 'any'`, with the `io: 'input'` retry) | Every `description` in all 27 type schemas | | R2 | `/meta/types` `form`: all 17 `METADATA_FORM_REGISTRY` layouts | Every `description` / `helpText` / `label` / `placeholder` / `hint` | | R3 | The package dialog, which derives its form from `ManifestSchema` client-side (objectui `package-schema.ts`) | Every `description` | | R4 | The flow inspector, which reads node `configSchema` from `GET /api/v1/automation/actions` | An AST string scan of every file that declares a `configSchema` (13 files) | | R5 | Refusals read at a door | An AST scan of every string literal in `packages/spec/src` (1284 files, 80190 string parts, comments excluded) | The class patterns were: interface `\bI[A-Z]\w*(Service\|Driver\|Engine\|…)\b`; ruling `\bruling\b\|\bruled\b\|\b20\d\d-\d\d-\d\d\b`; foreign `steedos\|superset\|salesforce\|apache\|airtable\|…`. **Positive control: both strings the filer measured are found.** - The notify Template help is found by R4 at `service-automation/src/builtin/notify-node.ts:203`, as both an interface hit and a ruling hit. The spec copy is found by R5 at `io-node-config.zod.ts:250`. - `com.steedos.crm` is found by R5 at `manifest.zod.ts:283`. R1 to R3 found neither string (needle count 0). So the Studio-rendered Template help is produced by the service-automation descriptor, not by the spec describe. That is why the producer side is changed too. **After the change**, R1 to R3 hold no interface, ruling or foreign-id hit. The one exception is the "Airtable parity" wording (see Acceptance notes). The needles `II18nService`, `IEmailService`, `com.steedos.crm` and `maintainer ruling` count 0. The R5 hit count drops by 22. ## Every string changed Lines are at `a543e244f`. Before is the internal reference that was removed; After is what replaced it. No changed string carried a tracker number, so no tracker number moved. | # | Where | Reach | Before | After | Rationale now lives in | | :-- | :--- | :--- | :--- | :--- | :--- | | 1 | `services/service-automation/src/builtin/notify-node.ts:203`, notify `configSchema.properties.template.description` | R4: Studio notify Template help (filer-measured) | "else the deployment default (II18nService.getDefaultLocale()) … (maintainer ruling 2026-09-01). A producer-set payload.locale is not consulted." | "else the deployment default locale … The node's payload.locale is not consulted." | Comment above `template:` in the descriptor | | 2 | `spec/src/automation/io-node-config.zod.ts:250`, `NotifyConfigSchema.template` describe | Reference docs and published JSON Schema; the contract row 1 mirrors | Same as row 1, with backticks | Same as row 1 | TSDoc above `template` (it already named both; one sentence added) | | 3 | `spec/src/kernel/manifest.zod.ts:283`, `MANIFEST_ID_EXAMPLES` | R5: every package-id refusal (filer-measured on `POST /api/v1/packages`) | `'com.steedos.crm', 'org.apache.superset'` | `'com.acme.crm', 'org.example.help-desk'` | Doc comment on the constant | | 4 | `manifest.zod.ts:305`, `manifestIdRefusal` | R5: same doors | "Invalid package id 'VALUE' on `manifest.id`. Expected reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — …" | "Invalid package id 'VALUE'. A package id (`manifest.id`) is written in reverse-domain notation, like 'com.acme.crm' or 'org.example.help-desk' — …", with the rule and suggestion arm unchanged | TSDoc on `manifestIdRefusal` | | 5 | `manifest.zod.ts:360-361`, `@example` on `id` | TSDoc; held equal to row 3 | `com.steedos.crm`, `org.apache.superset` | `com.acme.crm`, `org.example.help-desk` | (none) | | 6 | `spec/src/data/field.zod.ts:1142`, `required` describe | R1: object, field | "(maintainer ruling 2026-08-18)" | Removed | TSDoc above `required` | | 7 | `field.zod.ts:1170`, `multiple` describe | R1: object, field | "(maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling … (maintainer ruling 2026-08-18)" | "on any other type, `radio` included, is REFUSED at parse …" | TSDoc above `multiple` (new Declarability paragraph) | | 8 | `spec/src/ui/view.zod.ts:1175` / `:1221`, `GROUPING_FIELD_RULING` in the padded grouping-field refusal | R5: view save | "… (ruled 2026-09-10.)" | Removed, constant deleted | Comment where the constant was | | 9 | `view.zod.ts:3444`, form-view field `visibleWhen` describe | R1: view | "refused at parse (ruled 2026-08-27):" | "refused at parse:" | TSDoc above | | 10 | `view.zod.ts:3655`, section `collapsible` describe | R1: view | "(ruled 2026-09-18)" | Removed | The collapse-pair TSDoc (already records the ruling) | | 11 | `view.zod.ts:3663`, section `collapsed` describe | R1: view | "(ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened)" | ", so writing `collapsed: true` alone is a correct way to say \"collapsed by default\"" | The collapse-pair TSDoc (already records the rejected letters) | | 12 | `view.zod.ts:3715`, form-view section `visibleWhen` describe | R1: view | "(ruled 2026-08-27)" | Removed | TSDoc above | | 13 | `view.zod.ts:3959`, `SUBMIT_REDIRECT_RULING` in seven `submitBehavior.url` refusals (`:3988`, `:3993`, `:4003`, `:4010`, `:4018`, `:4029`, `:4039`) | R5: form-view save | "(ruled 2026-08-11)" | Removed, constant deleted | Comment where the constant was | | 14 | `view.zod.ts:4097` / `:4123`, `FORM_VIEW_FEATURES_RULING` in the `features.*` predicate refusal | R5: form-view save | "scope root (ruled 2026-08-27)." | "scope root." | Comment where the constant was | | 15 | `view.zod.ts:4444`, redirect-arm `url` describe | R1: view | "…successful submit. Ruled 2026-08-11: (1) …" | "…successful submit. (1) …" | The `checkSubmitRedirectUrl` comment | | 16 | `spec/src/ui/page.zod.ts:941` (`kind`) and `:990` (`source`) describes | R1: page | "(ADR-0065; ADR-0080 amendment 2026-06-30)" | "(ADR-0065; ADR-0080)" | TSDoc above `kind` (new) and `source` (already) | | 17 | `spec/src/system/email-template.form.ts:19`, Identity section help. Its three translated values in `platform-objects/src/apps/translations/{zh-CN,ja-JP,es-ES}.metadata-forms.generated.ts:2183` are hand-written, and `en` is regenerated by `pnpm i18n:extract` | R2: email_template form | "Template identifier resolved by IEmailService.sendTemplate({ template: name, locale, ... })." | "Senders address this template by its name; the locale selects which language version of it is sent." | Comment above `description:` | | 18 | `spec/src/data/filter.zod.ts:475`, null ordering comparand refusal | R5: filter validation | "Ruled 2026-09-01: a null ordering comparand is refused at the validation entrance." | Sentence removed | Builder TSDoc | | 19 | `filter.zod.ts:564`, `{ $field }` in a list position refusal | R5 | "Ruled 2026-08-11: declared = enforced (ADR-0049)." | Sentence removed | Comment inside the builder | | 20 | `filter.zod.ts:589`, null list member refusal | R5 | "Ruled 2026-08-31: a null list member is refused at the validation entrance." | Sentence removed | Builder TSDoc | | 21 | `filter.zod.ts:801`, blank `$between` bound refusal | R5 | "Ruled 2026-09-17: a blank $between bound is refused at the validation entrance." | Sentence removed | Builder TSDoc | | 22 | `spec/src/ui/action.zod.ts:985`, action `description` describe (added in patch round 1) | R1: action, in the authoring (`io: 'input'`) derivation; the action Description help objectui#11785 measured | "(one dialog, not two —)": the dash an earlier strip of the cited ruling left dangling | "(one dialog, not two)" | Docblock above `description` (already cites the ruling; one sentence added) | The regenerated `content/docs/references/**` files follow from rows 2, 6, 7, 9 to 12, 15, 16 and 22. ## Remainder of the card and its route This PR delivers the card minus two named rows. It is therefore `Part of #22093`, and the card stays open for both. 1. **The governed `submitBehavior` row.** - What: `view.zod.ts:4478`, the top-level `submitBehavior` describe, keeps "(ruled 2026-08-11)". - Why it is not here: `gen:react-blocks` projects this describe into `skills/objectstack-ui/references/react-blocks.md`, which is a governed Tier H surface, so changing it here would make this whole sweep a Tier H landing. Measured: the regenerated skill file differs in exactly that one row. - Route: a separate Tier H draft PR carrying the one describe edit, the regenerated `react-blocks.md` row and its reference-docs row, landed on the maintainer's approval. - A comment beside the describe records the deferral. 2. **The `sys_email` field help naming `IEmailService.send`.** - What: `packages/platform-objects`, `en.objects.generated.ts:2500` and `:2508`. The source is in the `sys_email` object definitions. - Why it is not here: open PR #22087 regenerates the same four `*.objects.generated.ts` bundles, so folding it here would put two PRs on those bundles at once. - Route: the same class of edit, made after that PR lands. ## Pins - **Refusal `code` is asserted with the sentence:** - `manifest.test.ts` asserts `invalid_format`. - `view-form-features-root.test.ts` asserts `custom`. - **Negative pins on what the author reads.** No interface name, ruling date or foreign id appears in: - `io-node-config.test.ts` - `notify-node.test.ts`, on the Studio-served descriptor text - `manifest.test.ts`. Its headline sentence carries no `manifest.id`, the key is still named as a locator, and there is no steedos/superset/apache. - `filter.test.ts` - `view-form-features-root.test.ts` - `view-submit-redirect-url.test.ts` - **Patch round 1: `action-description.test.ts`.** It derives `ActionSchema` the way `/meta/types` serves it (`io: 'input'`) and asserts three things about the `description` help: - it is present; - no dash is left dangling before a close paren; - it has no ruling date, service interface or tracker id. - **Pins that changed direction.** These earlier pins asserted that the date was present: - `view-submit-redirect-url.test.ts`, 7 assertions: "cites the ruling so the refusal is traceable" - `view-form-features-root.test.ts:68` - `filter.test.ts:644` - `io-node-config.test.ts:347` and its `2026-09-01` pin They are now negative pins. `filter.test.ts:661` used the date as its discriminator between two messages. It now discriminates on the blank-bound headline. - No whole-prose pin was added. ## Where the ruling dates in refusals come from (lineage) This PR reverses no ruling. - The 2026-08-29 adjudication carried out #12522's charter (comment 5425845726): strip tracker ids, and keep ADR ids and migration commands. That charter does not mention ruling dates. - "ADR ids, protocol versions, error codes and ruling dates stay" is PR #13298's own commit note (fd289be), not ruled text. The code comments that grew from it, saying the date is "what a refused author can act on", are rewritten here. - Triage 6041940817 on this card directs that "rationale (interface names, dates, tracker ids) moves into code comments". This PR follows that direction for describes and refusals alike, and the seat confirmed it in patch round 1. ADR ids stay. ## Tests and gates **Patch round 1, on the merged head `026204631`.** - **Merge:** origin/main was merged at `54ace18c6` with `bash scripts/pm/os-regen-merge.sh`. - Merge commit `75cec8cd1`, parents `34bf72933` and `54ace18c6`. There were no conflicts. - Step 2 kept the branch's bytes of the 7 reference-docs files main had not moved. - The regeneration ran only after the merge commit (never in MERGE state). It reproduced main's side byte for byte; the only regenerated delta is the action row, in 3 docs files. - Main's landed migration entries are still present on the merged head. Three were checked by name: `flow-edge-unresolved-or-repeated-refused`, `sys-presence-organization-column-retired` and `sys-job-organization-column-retired`. - The delta against main is exactly this branch's 30 paths. - **Builds and regeneration, all under the verify lock:** - `pnpm --filter @objectstack/spec build` passed. - `gen:schema` and `gen:docs` passed. - The `service-automation^...` closure build (spec excluded) passed. - **spec tests:** `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2` over 26 targeted files (round 0's 25 plus `action-description.test.ts`): **26 files, 2475 tests passed.** This is a declared narrowing; CI `Test Core` runs the full suite. - **service-automation:** `notify-node.test.ts`, **16 passed.** - **platform-objects:** `src/apps/translations/*.test.ts`, **24 files, 430 passed.** - **Typecheck:** `@objectstack/spec` `typecheck` passed. - **Generated artifacts:** `pnpm --filter @objectstack/spec check:generated` reports **all 15 generated artifacts up to date** (react-blocks and api-surface included). - **Reverse verification, patch round 1 (one-off):** - `action.zod.ts` went back to the `54ace18c6` bytes; landing confirmed by `grep -c` = 1. - **1 of 16 tests in `action-description.test.ts` went red:** "carries no residue of a stripped reference". - The file was restored from HEAD, hash-identical to its HEAD blob, and the suite was green again (16 of 16). - **Reverse verification, round 0 (at `34bf72933`):** - The 5 touched sources went back to the `a543e244f` bytes. - **39 of 347 spec tests went red across all 5 changed spec test files, and 1 of 16 in `notify-node.test.ts`.** Every red is a pin this PR added or flipped. - Restored byte-identical to HEAD. - **Lint:** `pnpm exec eslint --no-inline-config --format json` over the 20 changed lintable files at `026204631`: **20 files, 0 errors, 0 warnings.** - Population: `eslint.config.mjs` matches `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`. - Invariance: the config never enables type-aware linting (no `parserOptions.project`, see its note near line 328), so this diff cannot move the verdict on any file it does not touch. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `026204631` derived 113 families (30 paths against merge base `54ace18c6`). - **109 run, all exit 0**, each exit code captured before any pipe. - **4 NOT MEASURED, recorded as reasoned claims and read from CI on the landing head:** `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`. In round 0 each exited 3 (PREREQUISITE NOT MET). `check:type-check-debt`'s re-measure is a 45-task workspace build and is not run outside the verify lock. - `--ran` reconciliation: **113 accounted, 109 run, 4 NOT MEASURED (claimed), 0 unrun.** ## Acceptance notes - **Measured but not changed, as outside the three classes or outside reach:** - `stack.zod.ts:458-459` (`IJobService`, `IEmailService.sendTemplate`): `defineStack` collection describes. R1 to R3 do not serve them, and they are not refusals. - Strings whose reader is a plugin, driver or API developer, for whom the interface is the contract they implement or call. None is in R1 to R3: - `data-engine.zod.ts:165,190` (`IDataEngine.find()`) - `hook.zod.ts:1269` (`IScopedContext`) - `query.zod.ts:429` - the `driver.zod.ts:287-388` refusals (`IDataDriver`) - `automation-api.zod.ts:452` - `execution-context.zod.ts:441` ("the 2026-09-03 ruling"): `preserveAudit` is server-constructed and never authored. - `plugin-rest-api.zod.ts:138` ("ruling record, 2026-09-01"): a tombstone on `RestApiEndpointSchema`. Its own comment records that nothing parses that schema outside its unit tests, so no door reaches it. - `component.zod.ts:365` ("Since the console release of 2026-08-21 (`c86185eb5`)"): a release date and a commit sha, not a ruling. - Undated ruling words. These carry no date and no "maintainer ruling", so they fall outside the class as the seat scoped it: - `action.zod.ts:1139` and `:2157` - `page.zod.ts:93` - `view-grouping-query.ts:433` - `context.zod.ts:50` - `protocol.zod.ts:1681` - `endpoint-publish-gate.ts:368,416` - the `error-code-ledger.zod.ts` notes - "Airtable parity" wording is the only R1 to R3 hit left: `page.zod.ts:911`, `page.form.ts:113`, `view.form.ts:164`, plus `view.zod.ts:1706`, `page.zod.ts:647` and `component.zod.ts:1766`. It is a design note naming a competitor, not an example id. - Salesforce mentions in `export.zod.ts:292` and `object.zod.ts:1528` are comparisons, not ids. - `src/migrations/**`, about 1,240 dated or "ruling" hits: upgrade-guide records, not a form or a door. - `manifest.zod.ts:546`, TSDoc `@example` on `dependencies` (`@steedos/plugin-auth`): TSDoc only. `packages/core/src/artifact-packages.ts:114` cites that exact example, so changing it moves a comment in core. - The `sys_email` field help naming `IEmailService.send` is the second named remainder; see "Remainder of the card and its route". - **objectui:** it has no copy of any changed string at `9990f9e` (grep for `II18nService`, `IEmailService`, `steedos.crm`, `Invalid package id`). --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 15ec50e commit 51290bc

30 files changed

Lines changed: 252 additions & 115 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/service-automation': patch
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
Form help and refusals an author reads no longer carry service-interface names, ruling dates or another product's ids
8+
9+
Clause-②: no
10+
11+
Wording only: no schema, key, type, export or error-code change.
12+
13+
- The notify node's Template help (the `NotifyConfigSchema.template` describe and the Studio
14+
inspector's copy in `@objectstack/service-automation`) names the deployment's default locale in
15+
product words instead of `II18nService.getDefaultLocale()`, and drops its ruling date.
16+
- `MANIFEST_ID_EXAMPLES` is now `com.acme.crm` and `org.example.help-desk` (was `com.steedos.crm`
17+
and `org.apache.superset`). The package-id refusal opens with the headline
18+
"Invalid package id 'VALUE'." and names the key in the sentence after it, so the headline alone
19+
carries no JSON path; the rule, the examples and the suggestion follow unchanged in substance. A
20+
caller that matched the old "on KEY. Expected reverse-domain notation" wording matches the
21+
headline, or compares against `manifestIdRefusal()` by reference, instead.
22+
- Ruling dates leave the describes Studio renders as form help: field `required` and `multiple`,
23+
form-view field and section `visibleWhen`, section `collapsible` / `collapsed`, the redirect
24+
arm's `submitBehavior.url`, and page `kind` / `source` (the ADR citations stay). They also
25+
leave the refusals for padded grouping field names, `submitBehavior.url`, `features.*` in a
26+
form-view predicate, and the four filter comparand refusals (null ordering comparand,
27+
`{ $field }` in a list position, null list member, blank `$between` bound). Each sentence still
28+
states the rule, why it exists and the repair.
29+
- The email-template form's Identity section help says how senders address a template instead of
30+
naming `IEmailService.sendTemplate`, in all four shipped locales.
31+
- The action `description` help no longer ends in a dangling dash left behind by an earlier
32+
strip: "(one dialog, not two —)" now reads "(one dialog, not two)".

‎content/docs/references/automation/io-node-config.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ const result = HttpConfigSchema.parse(data);
9797
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
9898
| **title** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification title — a template: a bare string, or a `{ dialect: 'template', source }` envelope (the `tmpl` helper) carrying the same text. It is interpolated per run with the flow's single-brace `{token}` placeholders (`{record.name}`); a `{{var}}` is not a placeholder here — its inner `{var}` resolves and the outer braces stay in the text. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
9999
| **message** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification body — the same template input as `title` (a bare string or a `{ dialect: 'template', source }` envelope), interpolated per run with single-brace `{token}` placeholders; not localizable. Only valid with inline `title`, never with `template`. |
100-
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default (`II18nService.getDefaultLocale()`) — so recipients whose personal languages differ receive different rows of the same bundle (maintainer ruling 2026-09-01). A producer-set `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
100+
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default locale — so recipients whose personal languages differ receive different rows of the same bundle. The node's `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
101101
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
102102
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
103103
| **topic** | `string` | optional | Event topic (default: "notify") |

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ const result = CurrencyConfigSchema.parse(data);
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 |
5959
| **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`. |
60-
| **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. |
60+
| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
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 |
63-
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). |
63+
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. |
6464
| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. |
6565
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
6666
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |

0 commit comments

Comments
 (0)