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
17 changes: 17 additions & 0 deletions .changeset/19085-metadata-form-declared-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/spec": minor
"@objectstack/platform-objects": patch
---

`field.relatedListFilter` and `object.validations` are authorable in the metadata form. Both keys were **declared** by the served schema and offered by **no** form in `METADATA_FORM_REGISTRY`, so the generic metadata form never rendered a row for either and an author's only door was the Source tab — free-text JSON, where a mis-spelled sibling key is written, stored, and refused by the runtime later.

Measured on the tree before the change: zero rows for either key across every `*.form.ts` in `packages/spec/src`, with a lit control (`maskingRule`, offered twice) and a dark control (a name no form carries) in the same read — so the zero is a reading, not a dead probe.

**The face each row gets is a measurement, not a preference.** Both keys serve as JSON-Schema **pointer rows**, which is the shape a generic renderer cannot be assumed to resolve:

- **`field.relatedListFilter` → `widget: 'filter-condition'`.** The served node is `{ $ref: '#/$defs/…' }` onto the recursive Query-DSL `FilterCondition`, whose derivation is `allOf: [open record, { $and/$or/$not }]` with **no top-level `type`** — there is nothing for the generic renderer to derive a control from. `filter-condition` names the FilterCondition wire, and this file already uses it one section down for `summaryOperations.filter`, the sibling `FilterConditionSchema` key. What the hint renders as **today**, measured at the pinned `.objectui-sha`, is the announced **raw-JSON editor carrying the hint** — not a criteria builder: the renderer that consumes this registry is the metadata-admin `SchemaForm`, whose own `WIDGETS` map registers no `filter-condition` (the `FilterConditionField` of that name lives in `@object-ui/fields`, on the ComponentRegistry path `ObjectForm` uses), and with the pointer unresolved neither structural fallback applies, so `resolveFieldFace` lands on `{ kind: 'raw-json', hint }` — the same face `summaryOperations.filter` gets. That editor hands `JSON.parse` output through verbatim and the save door judges it, so the wire is exact either way; the hint is the forward-looking half. ⛔ Deliberately **not** `filter-builder`: that widget consumes a rule **ARRAY** (what `view.filter`, `dataset.filter` and `page.filterBy` store), so routing this key there would write metadata the runtime refuses — the authoring trap this row exists to close, re-created one layer up. `visibleWhen` mirrors the key's own contract text (`lookup` / `master_detail`), a meaningfulness gate rather than a parse gate: `FieldSchema` accepts the key on every type, but the related-list derivation only ever reads it on the child-side FK.
- **`object.validations` → `widget: 'json'`.** The served node is an array whose items are a **double-hop** pointer (`items.$ref` → `$defs/__schema1` → `$defs/__schema2`) landing on a `oneOf` over the six `ValidationRule` members. A repeater would have to resolve both hops **and** pick a union branch before it could render a row; neither half is measured for this node, and a repeater that resolves neither renders an empty row whose values never land — the offer-vs-door defect the reconciliation gate beside it exists to catch. The Zod parse still refuses a malformed rule loudly at publish. Precisely: `json` is in that renderer's passthrough set, but the set is consulted **after** the structural fallbacks, not instead of them — so this row reaches the raw-JSON editor because the unresolved double-hop pointer derives nothing, not because the hint suppresses derivation. Once the pin moves past objectui's pointer resolution the same hint derives an `object-rows` repeater over the first `oneOf` branch; that is the renderer's precedence, not this repo's contract. Same treatment as the sibling structured-array rows `permission.rowLevelSecurity` and `email_template.variables`. Upgrading it to a structured control is a form-face addition, ⛔ not a reconciliation.

A new pin (`metadata-form-declared-rows.pin.test.ts`) keeps both rows and both faces, and adds a registry-wide assertion — every row of every form, at every depth — that **no** form routes a `FilterCondition`-typed key to the rule-array builder, with a lit control proving the walk reaches both keys before it reports an empty misrouted set.

⛔ **No wire byte moves and no export changes.** `check:api-surface` and `check:api-surface-declarations` are green with no regeneration: `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly<Record<string, FormView>>`, so the row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks — hence the regenerated `platform-objects` metadata-form bundles (44 additive lines; the new `en` entries are source text, the translated locales still need translating).
17 changes: 16 additions & 1 deletion packages/lint/src/validate-predicate-path-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,22 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
// `data.type == 'page'` — went with the key it configured. A form input for
// an unwritable key is the false-compliant UI half of a retirement, so the
// census falls by exactly one.
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(52);
// It is 53 today, and this one is an ADDITION: #19085 gave
// `field.relatedListFilter` the form row its declaration always implied —
// the served schema declared the key while no form offered it, so an
// author's only door was the Source tab's free-text JSON. The row is gated
// `data.type in ['lookup','master_detail']`, mirroring the key's own
// contract text ("meaningful on a child's `master_detail`/`lookup` field")
// and the `reference` row beside it in the same form; `FieldSchema` accepts
// the key on every type, so that gate is a meaningfulness gate, not a parse
// gate — the same shape as the `valueDomain` entry above.
// Measured rather than inferred from the delta: the corpus was enumerated
// on this tree and on the merge base `eeaa882459`, and differenced by
// `<form>::<field>::<source>`. Exactly one entry was added —
// `field :: relatedListFilter :: data.type in ['lookup','master_detail']`
// — and NONE was removed. The same card's second row, `object.validations`,
// carries no predicate at all, so it does not enter this census.
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(53);

const findings = validatePredicatePathRefs(corrupted);
expect(findings).toHaveLength(predicates);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
"enable.clone": {
label: "Clone"
},
validations: {
label: "Validations",
helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)"
},
datasource: {
label: "Datasource",
helpText: "Target datasource ID (default: \"default\")"
Expand Down Expand Up @@ -424,6 +428,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Delete Behavior",
helpText: "What happens when referenced record is deleted"
},
relatedListFilter: {
label: "Related List Filter",
helpText: "Default filter for this relationship's related list on the parent's detail page — AND-composed with the parent-record match, and the tab badge counts the same set"
},
expression: {
label: "Expression",
helpText: "CEL expression to calculate this field (makes it read-only)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
"enable.clone": {
label: "Clone"
},
validations: {
label: "Validations",
helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)"
},
datasource: {
label: "Fuente de datos",
helpText: "ID de fuente de datos de destino (valor predeterminado: \"default\")"
Expand Down Expand Up @@ -424,6 +428,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "Comportamiento al eliminar",
helpText: "Qué ocurre cuando se elimina el registro referenciado"
},
relatedListFilter: {
label: "Related List Filter",
helpText: "Default filter for this relationship's related list on the parent's detail page — AND-composed with the parent-record match, and the tab badge counts the same set"
},
expression: {
label: "Expresión",
helpText: "Expresión CEL para calcular este campo (lo hace de solo lectura)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"metadataForms.email_template.fields.variables.helpText": "27197774c2cda633",
"metadataForms.field.fields.placeholder.helpText": "07cecac0844860be",
"metadataForms.field.fields.placeholder.label": "44d62b55b63fe718",
"metadataForms.field.fields.relatedListFilter.helpText": "4d4f65b4886840a7",
"metadataForms.field.fields.relatedListFilter.label": "0ba1fca22ec231f1",
"metadataForms.field.fields.rows.helpText": "66755b9447e67a15",
"metadataForms.field.fields.rows.label": "319af74cc41ea823",
"metadataForms.field.fields.summaryOperations.field.helpText": "b6897e7341b31c09",
Expand Down Expand Up @@ -161,6 +163,8 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"metadataForms.object.fields.lifecycle.ttl.field.label": "e21c314685cd95ca",
"metadataForms.object.fields.lifecycle.ttl.helpText": "9b125bc1584a941f",
"metadataForms.object.fields.lifecycle.ttl.label": "e37616f70b0d157a",
"metadataForms.object.fields.validations.helpText": "1f6e59e364995ce8",
"metadataForms.object.fields.validations.label": "06fc6dd288348779",
"metadataForms.page.fields.interfaceConfig.addRecord.helpText": "3eb7b86c3a630db9",
"metadataForms.page.fields.interfaceConfig.addRecord.label": "01068706d8b5418a",
"metadataForms.page.fields.interfaceConfig.allowPrinting.helpText": "b8fa84e13cd1432c",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
"enable.clone": {
label: "Clone"
},
validations: {
label: "Validations",
helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)"
},
datasource: {
label: "データソース",
helpText: "対象データソース ID(既定: \"default\")"
Expand Down Expand Up @@ -424,6 +428,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "削除動作",
helpText: "参照先レコード削除時の動作"
},
relatedListFilter: {
label: "Related List Filter",
helpText: "Default filter for this relationship's related list on the parent's detail page — AND-composed with the parent-record match, and the tab badge counts the same set"
},
expression: {
label: "式",
helpText: "このフィールドを計算する CEL 式(読み取り専用化)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
"metadataForms.email_template.fields.variables.helpText": "27197774c2cda633",
"metadataForms.field.fields.placeholder.helpText": "07cecac0844860be",
"metadataForms.field.fields.placeholder.label": "44d62b55b63fe718",
"metadataForms.field.fields.relatedListFilter.helpText": "4d4f65b4886840a7",
"metadataForms.field.fields.relatedListFilter.label": "0ba1fca22ec231f1",
"metadataForms.field.fields.rows.helpText": "66755b9447e67a15",
"metadataForms.field.fields.rows.label": "319af74cc41ea823",
"metadataForms.field.fields.summaryOperations.field.helpText": "b6897e7341b31c09",
Expand Down Expand Up @@ -161,6 +163,8 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
"metadataForms.object.fields.lifecycle.ttl.field.label": "e21c314685cd95ca",
"metadataForms.object.fields.lifecycle.ttl.helpText": "9b125bc1584a941f",
"metadataForms.object.fields.lifecycle.ttl.label": "e37616f70b0d157a",
"metadataForms.object.fields.validations.helpText": "1f6e59e364995ce8",
"metadataForms.object.fields.validations.label": "06fc6dd288348779",
"metadataForms.page.fields.interfaceConfig.addRecord.helpText": "3eb7b86c3a630db9",
"metadataForms.page.fields.interfaceConfig.addRecord.label": "01068706d8b5418a",
"metadataForms.page.fields.interfaceConfig.allowPrinting.helpText": "b8fa84e13cd1432c",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
"enable.clone": {
label: "Clone"
},
validations: {
label: "Validations",
helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)"
},
datasource: {
label: "数据源",
helpText: "目标数据源 ID(默认:\"default\")"
Expand Down Expand Up @@ -424,6 +428,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "删除行为",
helpText: "被引用记录删除时的处理方式"
},
relatedListFilter: {
label: "Related List Filter",
helpText: "Default filter for this relationship's related list on the parent's detail page — AND-composed with the parent-record match, and the tab badge counts the same set"
},
expression: {
label: "表达式",
helpText: "用 CEL 表达式计算此字段的值(自动设为只读)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export const zhCNGeneratedSourceHashes: Readonly<Record<string, string>> = {
"metadataForms.doc.label": "3f35cf5088b999ad",
"metadataForms.field.fields.placeholder.helpText": "07cecac0844860be",
"metadataForms.field.fields.placeholder.label": "44d62b55b63fe718",
"metadataForms.field.fields.relatedListFilter.helpText": "4d4f65b4886840a7",
"metadataForms.field.fields.relatedListFilter.label": "0ba1fca22ec231f1",
"metadataForms.field.fields.rows.helpText": "66755b9447e67a15",
"metadataForms.field.fields.rows.label": "319af74cc41ea823",
"metadataForms.field.fields.summaryOperations.field.helpText": "b6897e7341b31c09",
Expand Down Expand Up @@ -128,6 +130,8 @@ export const zhCNGeneratedSourceHashes: Readonly<Record<string, string>> = {
"metadataForms.object.fields.fields.valueDomain.label": "18ebeb7e56e792a1",
"metadataForms.object.fields.fields.visibleWhen.helpText": "46de2d3ff57b6667",
"metadataForms.object.fields.fields.visibleWhen.label": "c852d4249db93285",
"metadataForms.object.fields.validations.helpText": "1f6e59e364995ce8",
"metadataForms.object.fields.validations.label": "06fc6dd288348779",
"metadataForms.page.fields.interfaceConfig.addRecord.helpText": "3eb7b86c3a630db9",
"metadataForms.page.fields.interfaceConfig.addRecord.label": "01068706d8b5418a",
"metadataForms.page.fields.interfaceConfig.allowPrinting.helpText": "b8fa84e13cd1432c",
Expand Down
43 changes: 43 additions & 0 deletions packages/spec/src/data/field.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,49 @@ export const fieldForm = defineForm({
{ label: 'Cascade (delete children)', value: 'cascade' },
{ label: 'Restrict (block the delete)', value: 'restrict' },
] },
// #19085 — `relatedListFilter` gets the row its declaration always
// implied. The served schema DECLARED the key and no form offered it,
// so an author's only door was the Source tab's free-text JSON, where
// nothing validates the sibling key they invent until the runtime
// refuses it.
//
// The face is `filter-condition`, ⛔ NOT `filter-builder`: the two
// widgets speak different wires. `filter-builder` consumes a rule
// ARRAY (what `view.filter`, `dataset.filter` and `page.filterBy`
// store); this key is a canonical Query-DSL `FilterCondition` — an
// object keyed by field, with `$and`/`$or`/`$not`. Routing it to the
// array widget would write metadata the runtime refuses, which is the
// authoring trap this row exists to close, re-created one layer up.
// `filter-condition` names the FilterCondition wire, and this file
// already uses it one section down for `summaryOperations.filter`, the
// sibling FilterConditionSchema key.
//
// ⚠ What the hint renders as TODAY, measured at the pinned
// `.objectui-sha`, is the announced raw-JSON editor carrying the hint —
// ⛔ NOT a criteria builder. The renderer that consumes this registry is
// the metadata-admin `SchemaForm`, whose own `WIDGETS` map registers no
// `filter-condition` (the `FilterConditionField` of that name lives in
// `@object-ui/fields`, on the ComponentRegistry path `ObjectForm` uses,
// not this one), and its `resolveFieldFace` falls past the registry,
// past both structural fallbacks — the served node is
// `{ $ref: '#/$defs/…' }` onto the recursive FilterCondition, an
// `allOf: [open record, { $and/$or/$not }]` with NO top-level `type`,
// so neither an object form nor an array-of-objects can be derived
// (objectui#9912 measured the same for 12 of the 14 served pointer
// rows) — and lands on `{ kind: 'raw-json', hint }`. That is the same
// face `summaryOperations.filter` gets, it hands `JSON.parse` output
// through verbatim, and the save door judges it, so the wire is exact
// either way. The hint is the forward-looking half: it is what a
// renderer resolves when it can, and it is ⛔ never `filter-builder`.
//
// `visibleWhen` mirrors the key's own contract text — "it is
// meaningful on a child's `master_detail`/`lookup` field" — and the
// `reference` row above. The schema accepts the key on every type, so
// this is a MEANINGFULNESS gate, not a parse gate: on a non-reference
// field the related-list derivation never reads it, and offering a
// knob the runtime does not deliver is what Prime Directive #10
// forbids.
{ field: 'relatedListFilter', widget: 'filter-condition', visibleWhen: "data.type in ['lookup','master_detail']", helpText: "Default filter for this relationship's related list on the parent's detail page — AND-composed with the parent-record match, and the tab badge counts the same set" },
],
},
{
Expand Down
Loading
Loading