Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .changeset/metadata-form-zod-reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
"@objectstack/spec": minor
"@objectstack/rest": patch
"@objectstack/platform-objects": patch
---

fix(spec,rest): the metadata forms save what they show — form ↔ Zod reconciliation (#3786)

Every entry in `METADATA_FORM_REGISTRY` is a hand-written `defineForm` layout
that names keys of a Zod schema it never imports: two descriptions of one key
set, a comment asking the next author to keep them in step, and nothing that
fails when they don't. #3786 asked for a sweep of that shape across the repo.
**Four of the seventeen forms had already drifted, every one of them silently.**

The silence is the point. `ObjectSchema` / `FieldSchema` are deliberately not
`.strict()`, so a key the schema does not declare parses clean and is stripped
on the way to storage — the same ADR-0104 failure class the `field.zod.ts`
prune tombstone already describes in prose. An admin toggled a switch in
Studio, got no error, and the value never landed.

**What was broken, from an author's seat:**

- **Object → Capabilities.** The block bound to `capabilities`; the
`ObjectSchema` key is `enable`. All seven toggles (Track history, Searchable,
API enabled, Files, Feeds, Activities, Clone) saved nothing.
- **Object → Fields.** The inline column grid offered 16 keys `FieldSchema` has
never declared. `PII`, `Encrypted`, `Indexed`, `Immutable`, `Filterable`,
`Placeholder`, `Validation`/`Error message` and `Starting number` were
controls with no storage behind them at all; the rest named keys the schema
had **renamed** and the form never followed:
`referenceFilter` → `lookupFilters`, `cascadeDelete` → `deleteBehavior`
(a three-way enum, not a boolean), `formula` → `expression`,
`displayFormat` → `autonumberFormat`, and the flat `summaryType` /
`summaryField` pair → the single `summaryOperations` object, which also
restores the `object` key the flat pair had no slot for. Roll-ups authored in
that grid saved nothing.
- **Report → Advanced.** `aria` and `performance` were pruned from
`ReportSchema` by #3496; the form kept rendering both.
- **Hook / Action → Body.** `memoryMb` was unauthorable — named in
`hook.form.ts`'s own doc comment, absent from the list beneath it.
- **Page → Interface.** `interfaceConfig.sort` was unauthorable, so a page's
default sort order could not be set in Studio at all.

**No authored metadata changes and nothing you can write is removed.** These
were UI controls that never persisted; every corrected key is one `FieldSchema`
/ `ObjectSchema` already accepted. Metadata authored in YAML/TS was always
validated against the real schema and is unaffected. If you had been filling
those Studio controls expecting them to stick, they now either work (the
renamed five) or are gone rather than lying to you.

The metadata-form translation bundles are derived from the registry, so all
four locales are regenerated. Worth naming what they contained: translated
labels, in four languages, for switches that saved nothing — the drift had
propagated into a generated artifact and been dutifully translated there.

**The mechanism.** `metadata-form-zod-reconciliation.test.ts` walks every
registered form and reconciles it against `getMetadataTypeSchema()`. The two
directions are deliberately asymmetric: **form-only** (a control whose value is
discarded) is always a defect and cannot be excused, because no design wants
one; **zod-only** is ledgerable with a reason, for a deprecated key held back
from new authoring or a curated quick-add subset that defers to a fuller
editor. Ledger entries are checked for non-vacuity and for still resolving on
both sides, per the #4045 / #4040 discipline. Verified by mutation — re-adding
a stripped key, dropping a covered key, and offering a ledgered omission each
turn the gate red.

**New export: `TRANSLATABLE_METADATA_TYPES`** (`@objectstack/spec/system`), the
set of metadata types whose labels `translateMetadataDocument` localizes,
derived from its dispatch table rather than restated. `@objectstack/rest` had
been carrying a hand-copied literal set under a "keep in sync with the type
dispatch" comment; it now reads this instead. Registering a translator in spec
reaches the REST boundary with nothing else to remember — the second list is
deleted rather than checked, which is the better half of derive-or-gate.

Also corrected: `ActionAiCategorySchema`'s comment claimed it mirrored
`ToolCategorySchema` in `ai/tool.zod` and told the next author to update both
sides — but #3896 deleted `ToolCategorySchema` along with the inert
`tool.category` key it typed. The instruction had been pointing at a source
that no longer exists. The enum is canonical now and says so.
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Unique",
helpText: "Disallow duplicate values"
},
"fields.indexed": {
label: "Indexed",
helpText: "Create a database index for faster querying"
},
"fields.readonly": {
label: "Readonly",
helpText: "Visible but never user-editable"
},
"fields.immutable": {
label: "Immutable",
helpText: "Editable on create, locked thereafter"
},
"fields.hidden": {
label: "Hidden",
helpText: "Hidden from default UI"
Expand All @@ -102,18 +94,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Sortable",
helpText: "Allow sorting on this column"
},
"fields.filterable": {
label: "Filterable",
helpText: "Allow filtering on this column"
},
"fields.defaultValue": {
label: "Default Value",
helpText: "Default value for new records (JSON literal)"
},
"fields.placeholder": {
label: "Placeholder",
helpText: "Placeholder hint"
},
"fields.maxLength": {
label: "Max Length",
helpText: "Max characters"
Expand Down Expand Up @@ -162,93 +146,89 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Reference",
helpText: "Target object (for lookup/master_detail)"
},
"fields.referenceFilter": {
label: "Reference Filter",
helpText: "CEL filter applied to the picker"
"fields.lookupFilters": {
label: "Lookup Filters",
helpText: "Filter rules applied to the picker ({field, operator, value})"
},
"fields.cascadeDelete": {
label: "Cascade Delete",
helpText: "Delete children when parent is deleted"
"fields.deleteBehavior": {
label: "Delete Behavior",
helpText: "What happens when the referenced record is deleted"
},
"fields.multiple": {
label: "Multiple",
helpText: "Allow selecting multiple records"
},
"fields.formula": {
label: "Formula",
"fields.expression": {
label: "Expression",
helpText: "CEL formula expression"
},
"fields.returnType": {
label: "Return Type",
helpText: "Result type for formulas"
},
"fields.summaryType": {
label: "Summary Type",
helpText: "Aggregation"
"fields.summaryOperations": {
label: "Summary Operations",
helpText: "Roll-up: which child object, which field, which aggregation"
},
"fields.summaryOperations.object": {
label: "Object",
helpText: "Source child object name"
},
"fields.summaryField": {
label: "Summary Field",
helpText: "Field on child object to aggregate"
"fields.summaryOperations.field": {
label: "Field",
helpText: "Field on the child object to aggregate (ignored for count)"
},
"fields.displayFormat": {
label: "Display Format",
helpText: "e.g. \"INV-{0000}\""
"fields.summaryOperations.function": {
label: "Function",
helpText: "Aggregation function"
},
"fields.startingNumber": {
label: "Starting Number",
helpText: "Starting sequence value"
"fields.autonumberFormat": {
label: "Autonumber Format",
helpText: "e.g. \"INV-{0000}\"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported"
},
"fields.language": {
label: "Language",
helpText: "Editor language (e.g. sql, javascript)"
},
"fields.validation": {
label: "Validation",
helpText: "CEL predicate — must evaluate true"
},
"fields.errorMessage": {
label: "Error Message",
helpText: "Shown when validation fails"
},
"fields.audit": {
label: "Audit",
helpText: "Audit changes to this field"
},
"fields.trackHistory": {
label: "Track History",
helpText: "Keep change history"
},
"fields.pii": {
label: "Pii",
helpText: "Personally identifiable information"
"fields.visibleWhen": {
label: "Visible When",
helpText: "CEL predicate — field is shown only when TRUE"
},
"fields.encrypted": {
label: "Encrypted",
helpText: "Encrypt at rest"
"fields.readonlyWhen": {
label: "Readonly When",
helpText: "CEL predicate — field is read-only when TRUE (enforced server-side)"
},
capabilities: {
label: "Capabilities",
"fields.requiredWhen": {
label: "Required When",
helpText: "CEL predicate — field is required when TRUE (enforced server-side)"
},
enable: {
label: "Enable",
helpText: "Enable/disable system features"
},
"capabilities.trackHistory": {
"enable.trackHistory": {
label: "Track History"
},
"capabilities.searchable": {
"enable.searchable": {
label: "Searchable"
},
"capabilities.apiEnabled": {
"enable.apiEnabled": {
label: "Api Enabled"
},
"capabilities.files": {
"enable.files": {
label: "Files"
},
"capabilities.feeds": {
"enable.feeds": {
label: "Feeds"
},
"capabilities.activities": {
"enable.activities": {
label: "Activities"
},
"capabilities.clone": {
"enable.clone": {
label: "Clone"
},
datasource: {
Expand Down Expand Up @@ -529,6 +509,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Timeout Ms",
helpText: "Per-invocation timeout (ms)"
},
"body.memoryMb": {
label: "Memory Mb",
helpText: "Per-invocation memory cap (MB, max 256)"
},
handler: {
label: "Handler",
helpText: "Handler function name (deprecated — prefer `body`)"
Expand Down Expand Up @@ -819,6 +803,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Filter By",
helpText: "Always-on base filter for the page — same visual builder as the list toolbar."
},
"interfaceConfig.sort": {
label: "Sort",
helpText: "Default sort order for the page, defined directly on the page."
},
"interfaceConfig.levels": {
label: "Levels",
helpText: "Hierarchy levels to display (tree-like sources)"
Expand Down Expand Up @@ -1111,6 +1099,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Timeout Ms",
helpText: "Per-invocation timeout (ms)"
},
"body.memoryMb": {
label: "Memory Mb",
helpText: "Per-invocation memory cap (MB, max 256)"
},
params: {
label: "Params",
helpText: "User input parameters (show form before executing)"
Expand Down Expand Up @@ -1179,10 +1171,6 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
filter_and_chart: {
label: "Filter & chart",
description: "Report-level filters and chart presentation."
},
advanced: {
label: "Advanced",
description: "Accessibility and performance tuning."
}
},
fields: {
Expand Down Expand Up @@ -1235,14 +1223,6 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
chart: {
label: "Chart",
helpText: "Chart config (type, legend, colors)"
},
aria: {
label: "Aria",
helpText: "Accessibility labels"
},
performance: {
label: "Performance",
helpText: "Caching and optimization"
}
}
},
Expand Down
Loading
Loading