Skip to content
Merged
23 changes: 23 additions & 0 deletions .changeset/19331-scalar-form-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@objectstack/spec": minor
"@objectstack/platform-objects": patch
---

45 declared-but-unoffered scalar metadata keys are authorable in the metadata form. Each was **declared** by an object-rooted metadata schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY` — so the generic metadata form rendered no row for any of them 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.

**The population was re-derived, not inherited.** The reconciliation gate's own helper block (`packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`, lines 104-469 verbatim) was run over the live registry with a lit control (`name`, offered by 17 of 17 forms) and a dark control (a fabricated key, 0 forms and 0 schemas) asserted in the same probe. Readings on the tree this change starts from: **142** top-level zod-only keys across the 17 forms once the ADR-0010 provenance overlay is skipped, **94** of them on the 11 object-rooted types (`view` is union-rooted and contributes the other 48), **87** of those graded `live`, and **49** of those resolving to a scalar schema node. After the change the same probe reads 4, which are the four rows deliberately not landed.

**Four keys are deliberately still unoffered**, each because a control for it would be an authoring trap rather than an offer:

- `object.displayNameField` — `[DEPRECATED → nameField]`. Its canonical replacement `nameField` lands here; offering the alias beside it would teach an author the retired spelling.
- `app._unpublished` — the schema's own text says `Never authored`: a machine-managed publish gate written by the AI materialization path and cleared by publish-drafts.
- `field.system` — the auto-injected/system-column marker the platform stamps (`applySystemFields`, the search companion). It is read widely on the write path — the record validator skips required and multi-value checks for a flagged column — so a control for it lets an author assert a false provenance that silently disables validation for that field.
- `field.format` — **one `z.string()` key carrying three vocabularies**, so no help text can be written for it until someone rules which one it has. The engine reads it as an **autonumber pattern**: `resolveAutonumberFormat` (`packages/spec/src/data/autonumber-format.ts:196-202`) falls back from `autonumberFormat` to `format`, and `packages/objectql/src/engine.ts:5043-5051` calls it for every `autonumber` field — as does the SQL driver. objectui reads it as a **date display style**, `short` / `relative`, pinned at the `.objectui-sha` this repo builds against by `packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx` and `packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx`. The published `describe` names a third — `email`, `phone` — that **nothing measured honours**: an author who follows it on an autonumber field gets the literal string `email` rendered as their number.

**The control follows the scalar type and the copy states what the runtime enforces**, including what ABSENCE resolves to, which is the half an author cannot read off an enum: `object.sharingModel` says a custom object that omits it resolves to `private`; `field.step` says the write path does not reject a value off the step grid; `action.undoable` says an action with no `operation` has no write set to capture. Nineteen rows carry a `visibleWhen` MEANINGFULNESS gate mirrored from the same key's row in the object designer's quick-add grid — the schema accepts each key whatever the sibling value is, but only some field types, page kinds or action operations ever read it.

Three enums (`object.managedBy`, `action.execution`, `action.openIn`) deliberately carry **no** inline `options` list: `FormSelectOptionSchema.value` is a system identifier (`^[a-z][a-z0-9_.]*$`), so members such as `system-data`, `engine-owned` or `perRecord` cannot be spelled as option values at all. Those rows derive their enum from the served JSON Schema, which carries every member verbatim, and the meanings ride the help text.

⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly<Record<string, FormView>>`, so 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, whose 90 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills, because those three catalogs are ratcheted against undecided echoes.

⛔ **The gate that would notice a missing row is NOT landed here.** The top-level `zodOnly` direction of the reconciliation gate stays unwired: turning it on today would turn the remaining absences into red lines with no offers behind them, which is the shape the census round explicitly refused. This change lands offers; the assertion is a separate card.
33 changes: 31 additions & 2 deletions packages/lint/src/validate-predicate-path-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,27 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
// `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);
// It is 72 today, and this one is an ADDITION of NINETEEN: #19331 gave the
// 45 declared-but-unoffered object-rooted live scalar keys a form row each,
// and nineteen of those rows carry a MEANINGFULNESS gate — the schema
// accepts each key whatever the sibling value is, but only some field
// types, page kinds or action operations ever read it, and offering a knob
// the runtime does not deliver is what Prime Directive #10 forbids. Fifteen
// are `field.form.ts` rows mirrored from the same key's row in the object
// designer's quick-add grid, so the two surfaces cannot disagree about when
// a knob applies; the other four are `page :: source`, `action :: openIn`,
// `action :: newTabUrl` and `action :: undoable`.
// Measured rather than inferred from the delta: the shipped `*.form.ts`
// corpus was differenced against the merge base `5ce3705052` by
// `<form>::<field>::<source>`. Exactly nineteen entries were added and NONE
// was removed — `field :: ackPlaintextMasking | allowCreate |
// autonumberFormat | descriptionField | dimensions | displayField |
// inlineAmountField | inlineTitle | language | lookupPageSize | maxSize |
// referenceVia | relatedListTitle | returnType | step`, plus
// `page :: source`, `action :: openIn`, `action :: newTabUrl` and
// `action :: undoable`. The other twenty-six rows that card landed carry no
// predicate at all, so they do not enter this census.
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(72);

const findings = validatePredicatePathRefs(corrupted);
expect(findings).toHaveLength(predicates);
Expand Down Expand Up @@ -651,7 +671,16 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
// section with the `type: 'page'` mount it configured (ADR-0049
// enforce-or-remove), taking that one `==` literal comparison with it. The
// seven sibling surface blocks that share its shape are untouched.
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(41);
// It is 52 today: eleven of #19331's nineteen new predicates compare
// against a single-quoted literal (`data.type == 'password'`,
// `== 'autonumber'`, `== 'vector'`, two `== 'master_detail'`, `== 'code'`,
// `== 'url'`, `== 'text'`, `== 'formula'`, `== 'slider'`,
// `data.operation == 'update'`). The other eight do not reach this rule:
// seven are `in`-list gates, whose literals belong to `in` and are
// deliberately outside this rule (see the anchor note above), and
// `action :: newTabUrl` compares against the bare `true`, which is not a
// quoted literal and is not rewritten.
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(52);

const rhsFindings = validatePredicatePathRefs(corrupted)
.filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);
Expand Down
Loading
Loading