Skip to content

Commit 20bc1ec

Browse files
authored
fix(spec,rest): reconcile the metadata forms against their Zod — four silent drifts (#3786) (#4120)
Every entry in METADATA_FORM_REGISTRY is a hand-written `defineForm` layout naming keys of a Zod schema it never imports. Four of the seventeen had already drifted, each silently — the schemas are deliberately not `.strict()`, so a key they do not declare parses clean and is stripped on the way to storage. - object: `capabilities` is not a key (it is `enable`) — 7 toggles saved nothing - object: the inline column grid offered 16 keys FieldSchema never declared - report: `aria`/`performance`, pruned by #3496, still rendered - hook/action: `body.memoryMb` was unauthorable - page: `interfaceConfig.sort` was unauthorable The gate is metadata-form-zod-reconciliation.test.ts, reconciling every registered form against getMetadataTypeSchema(). form-only is always a defect and not ledgerable; zod-only is ledgerable with a reason, checked for non-vacuity and for still resolving on both sides. Also: TRANSLATABLE_META_TYPES in rest is now derived from the translator dispatch (exported as TRANSLATABLE_METADATA_TYPES), and ActionAiCategorySchema's "update both sides" comment — whose source #3896 deleted — is retired.
1 parent 554ff92 commit 20bc1ec

15 files changed

Lines changed: 740 additions & 379 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": patch
4+
"@objectstack/platform-objects": patch
5+
---
6+
7+
fix(spec,rest): the metadata forms save what they show — form ↔ Zod reconciliation (#3786)
8+
9+
Every entry in `METADATA_FORM_REGISTRY` is a hand-written `defineForm` layout
10+
that names keys of a Zod schema it never imports: two descriptions of one key
11+
set, a comment asking the next author to keep them in step, and nothing that
12+
fails when they don't. #3786 asked for a sweep of that shape across the repo.
13+
**Four of the seventeen forms had already drifted, every one of them silently.**
14+
15+
The silence is the point. `ObjectSchema` / `FieldSchema` are deliberately not
16+
`.strict()`, so a key the schema does not declare parses clean and is stripped
17+
on the way to storage — the same ADR-0104 failure class the `field.zod.ts`
18+
prune tombstone already describes in prose. An admin toggled a switch in
19+
Studio, got no error, and the value never landed.
20+
21+
**What was broken, from an author's seat:**
22+
23+
- **Object → Capabilities.** The block bound to `capabilities`; the
24+
`ObjectSchema` key is `enable`. All seven toggles (Track history, Searchable,
25+
API enabled, Files, Feeds, Activities, Clone) saved nothing.
26+
- **Object → Fields.** The inline column grid offered 16 keys `FieldSchema` has
27+
never declared. `PII`, `Encrypted`, `Indexed`, `Immutable`, `Filterable`,
28+
`Placeholder`, `Validation`/`Error message` and `Starting number` were
29+
controls with no storage behind them at all; the rest named keys the schema
30+
had **renamed** and the form never followed:
31+
`referenceFilter``lookupFilters`, `cascadeDelete``deleteBehavior`
32+
(a three-way enum, not a boolean), `formula``expression`,
33+
`displayFormat``autonumberFormat`, and the flat `summaryType` /
34+
`summaryField` pair → the single `summaryOperations` object, which also
35+
restores the `object` key the flat pair had no slot for. Roll-ups authored in
36+
that grid saved nothing.
37+
- **Report → Advanced.** `aria` and `performance` were pruned from
38+
`ReportSchema` by #3496; the form kept rendering both.
39+
- **Hook / Action → Body.** `memoryMb` was unauthorable — named in
40+
`hook.form.ts`'s own doc comment, absent from the list beneath it.
41+
- **Page → Interface.** `interfaceConfig.sort` was unauthorable, so a page's
42+
default sort order could not be set in Studio at all.
43+
44+
**No authored metadata changes and nothing you can write is removed.** These
45+
were UI controls that never persisted; every corrected key is one `FieldSchema`
46+
/ `ObjectSchema` already accepted. Metadata authored in YAML/TS was always
47+
validated against the real schema and is unaffected. If you had been filling
48+
those Studio controls expecting them to stick, they now either work (the
49+
renamed five) or are gone rather than lying to you.
50+
51+
The metadata-form translation bundles are derived from the registry, so all
52+
four locales are regenerated. Worth naming what they contained: translated
53+
labels, in four languages, for switches that saved nothing — the drift had
54+
propagated into a generated artifact and been dutifully translated there.
55+
56+
**The mechanism.** `metadata-form-zod-reconciliation.test.ts` walks every
57+
registered form and reconciles it against `getMetadataTypeSchema()`. The two
58+
directions are deliberately asymmetric: **form-only** (a control whose value is
59+
discarded) is always a defect and cannot be excused, because no design wants
60+
one; **zod-only** is ledgerable with a reason, for a deprecated key held back
61+
from new authoring or a curated quick-add subset that defers to a fuller
62+
editor. Ledger entries are checked for non-vacuity and for still resolving on
63+
both sides, per the #4045 / #4040 discipline. Verified by mutation — re-adding
64+
a stripped key, dropping a covered key, and offering a ledgered omission each
65+
turn the gate red.
66+
67+
**New export: `TRANSLATABLE_METADATA_TYPES`** (`@objectstack/spec/system`), the
68+
set of metadata types whose labels `translateMetadataDocument` localizes,
69+
derived from its dispatch table rather than restated. `@objectstack/rest` had
70+
been carrying a hand-copied literal set under a "keep in sync with the type
71+
dispatch" comment; it now reads this instead. Registering a translator in spec
72+
reaches the REST boundary with nothing else to remember — the second list is
73+
deleted rather than checked, which is the better half of derive-or-gate.
74+
75+
Also corrected: `ActionAiCategorySchema`'s comment claimed it mirrored
76+
`ToolCategorySchema` in `ai/tool.zod` and told the next author to update both
77+
sides — but #3896 deleted `ToolCategorySchema` along with the inert
78+
`tool.category` key it typed. The instruction had been pointing at a source
79+
that no longer exists. The enum is canonical now and says so.

packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts

Lines changed: 55 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
7878
label: "Unique",
7979
helpText: "Disallow duplicate values"
8080
},
81-
"fields.indexed": {
82-
label: "Indexed",
83-
helpText: "Create a database index for faster querying"
84-
},
8581
"fields.readonly": {
8682
label: "Readonly",
8783
helpText: "Visible but never user-editable"
8884
},
89-
"fields.immutable": {
90-
label: "Immutable",
91-
helpText: "Editable on create, locked thereafter"
92-
},
9385
"fields.hidden": {
9486
label: "Hidden",
9587
helpText: "Hidden from default UI"
@@ -102,18 +94,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
10294
label: "Sortable",
10395
helpText: "Allow sorting on this column"
10496
},
105-
"fields.filterable": {
106-
label: "Filterable",
107-
helpText: "Allow filtering on this column"
108-
},
10997
"fields.defaultValue": {
11098
label: "Default Value",
11199
helpText: "Default value for new records (JSON literal)"
112100
},
113-
"fields.placeholder": {
114-
label: "Placeholder",
115-
helpText: "Placeholder hint"
116-
},
117101
"fields.maxLength": {
118102
label: "Max Length",
119103
helpText: "Max characters"
@@ -162,93 +146,89 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
162146
label: "Reference",
163147
helpText: "Target object (for lookup/master_detail)"
164148
},
165-
"fields.referenceFilter": {
166-
label: "Reference Filter",
167-
helpText: "CEL filter applied to the picker"
149+
"fields.lookupFilters": {
150+
label: "Lookup Filters",
151+
helpText: "Filter rules applied to the picker ({field, operator, value})"
168152
},
169-
"fields.cascadeDelete": {
170-
label: "Cascade Delete",
171-
helpText: "Delete children when parent is deleted"
153+
"fields.deleteBehavior": {
154+
label: "Delete Behavior",
155+
helpText: "What happens when the referenced record is deleted"
172156
},
173157
"fields.multiple": {
174158
label: "Multiple",
175159
helpText: "Allow selecting multiple records"
176160
},
177-
"fields.formula": {
178-
label: "Formula",
161+
"fields.expression": {
162+
label: "Expression",
179163
helpText: "CEL formula expression"
180164
},
181165
"fields.returnType": {
182166
label: "Return Type",
183167
helpText: "Result type for formulas"
184168
},
185-
"fields.summaryType": {
186-
label: "Summary Type",
187-
helpText: "Aggregation"
169+
"fields.summaryOperations": {
170+
label: "Summary Operations",
171+
helpText: "Roll-up: which child object, which field, which aggregation"
172+
},
173+
"fields.summaryOperations.object": {
174+
label: "Object",
175+
helpText: "Source child object name"
188176
},
189-
"fields.summaryField": {
190-
label: "Summary Field",
191-
helpText: "Field on child object to aggregate"
177+
"fields.summaryOperations.field": {
178+
label: "Field",
179+
helpText: "Field on the child object to aggregate (ignored for count)"
192180
},
193-
"fields.displayFormat": {
194-
label: "Display Format",
195-
helpText: "e.g. \"INV-{0000}\""
181+
"fields.summaryOperations.function": {
182+
label: "Function",
183+
helpText: "Aggregation function"
196184
},
197-
"fields.startingNumber": {
198-
label: "Starting Number",
199-
helpText: "Starting sequence value"
185+
"fields.autonumberFormat": {
186+
label: "Autonumber Format",
187+
helpText: "e.g. \"INV-{0000}\"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported"
200188
},
201189
"fields.language": {
202190
label: "Language",
203191
helpText: "Editor language (e.g. sql, javascript)"
204192
},
205-
"fields.validation": {
206-
label: "Validation",
207-
helpText: "CEL predicate — must evaluate true"
208-
},
209-
"fields.errorMessage": {
210-
label: "Error Message",
211-
helpText: "Shown when validation fails"
212-
},
213-
"fields.audit": {
214-
label: "Audit",
215-
helpText: "Audit changes to this field"
216-
},
217193
"fields.trackHistory": {
218194
label: "Track History",
219195
helpText: "Keep change history"
220196
},
221-
"fields.pii": {
222-
label: "Pii",
223-
helpText: "Personally identifiable information"
197+
"fields.visibleWhen": {
198+
label: "Visible When",
199+
helpText: "CEL predicate — field is shown only when TRUE"
224200
},
225-
"fields.encrypted": {
226-
label: "Encrypted",
227-
helpText: "Encrypt at rest"
201+
"fields.readonlyWhen": {
202+
label: "Readonly When",
203+
helpText: "CEL predicate — field is read-only when TRUE (enforced server-side)"
228204
},
229-
capabilities: {
230-
label: "Capabilities",
205+
"fields.requiredWhen": {
206+
label: "Required When",
207+
helpText: "CEL predicate — field is required when TRUE (enforced server-side)"
208+
},
209+
enable: {
210+
label: "Enable",
231211
helpText: "Enable/disable system features"
232212
},
233-
"capabilities.trackHistory": {
213+
"enable.trackHistory": {
234214
label: "Track History"
235215
},
236-
"capabilities.searchable": {
216+
"enable.searchable": {
237217
label: "Searchable"
238218
},
239-
"capabilities.apiEnabled": {
219+
"enable.apiEnabled": {
240220
label: "Api Enabled"
241221
},
242-
"capabilities.files": {
222+
"enable.files": {
243223
label: "Files"
244224
},
245-
"capabilities.feeds": {
225+
"enable.feeds": {
246226
label: "Feeds"
247227
},
248-
"capabilities.activities": {
228+
"enable.activities": {
249229
label: "Activities"
250230
},
251-
"capabilities.clone": {
231+
"enable.clone": {
252232
label: "Clone"
253233
},
254234
datasource: {
@@ -529,6 +509,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
529509
label: "Timeout Ms",
530510
helpText: "Per-invocation timeout (ms)"
531511
},
512+
"body.memoryMb": {
513+
label: "Memory Mb",
514+
helpText: "Per-invocation memory cap (MB, max 256)"
515+
},
532516
handler: {
533517
label: "Handler",
534518
helpText: "Handler function name (deprecated — prefer `body`)"
@@ -819,6 +803,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
819803
label: "Filter By",
820804
helpText: "Always-on base filter for the page — same visual builder as the list toolbar."
821805
},
806+
"interfaceConfig.sort": {
807+
label: "Sort",
808+
helpText: "Default sort order for the page, defined directly on the page."
809+
},
822810
"interfaceConfig.levels": {
823811
label: "Levels",
824812
helpText: "Hierarchy levels to display (tree-like sources)"
@@ -1111,6 +1099,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
11111099
label: "Timeout Ms",
11121100
helpText: "Per-invocation timeout (ms)"
11131101
},
1102+
"body.memoryMb": {
1103+
label: "Memory Mb",
1104+
helpText: "Per-invocation memory cap (MB, max 256)"
1105+
},
11141106
params: {
11151107
label: "Params",
11161108
helpText: "User input parameters (show form before executing)"
@@ -1179,10 +1171,6 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
11791171
filter_and_chart: {
11801172
label: "Filter & chart",
11811173
description: "Report-level filters and chart presentation."
1182-
},
1183-
advanced: {
1184-
label: "Advanced",
1185-
description: "Accessibility and performance tuning."
11861174
}
11871175
},
11881176
fields: {
@@ -1235,14 +1223,6 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
12351223
chart: {
12361224
label: "Chart",
12371225
helpText: "Chart config (type, legend, colors)"
1238-
},
1239-
aria: {
1240-
label: "Aria",
1241-
helpText: "Accessibility labels"
1242-
},
1243-
performance: {
1244-
label: "Performance",
1245-
helpText: "Caching and optimization"
12461226
}
12471227
}
12481228
},

0 commit comments

Comments
 (0)