Skip to content

Commit 408ca2e

Browse files
os-justinclaude
andauthored
feat(spec): give the 45 declared-but-unoffered scalar metadata keys a form row each (#19673)
Fixes #19331 Clause-②: no Bucket B8 of the #19188 census: keys an **object-rooted** metadata schema declares, graded **`live`** by the liveness ledger, whose schema node is a **scalar**, and that **no** form in `METADATA_FORM_REGISTRY` offered. Each gets exactly one form row. **45 rows landed; 4 keys are deliberately still unoffered and named below.** ## The population was re-derived, and it is 49 — not 47 ⛔ No number was inherited. The reconciliation gate's **own helper block** was sliced verbatim — `packages/spec/src/system/metadata-form-zod-reconciliation.test.ts` lines 104-469, sha256 `25ef6c218394d1ea…` — into a throwaway probe beside it, run through `vitest` against the live registry, and deleted again. It is not in the diff. **Controls asserted inside the probe, not beside it:** | control | reading | |---|---| | LIT — `name` | offered by **17 of 17** forms | | DARK — a fabricated key, form side | **0** of 17 forms offer it | | DARK — the same key, schema side | **0** of 17 schemas declare it | **The query, and what it read** — for each of the 17 registered types: `offerableKeysAt(getMetadataTypeSchema(type), ROOT_PATH)` minus `topLevelFields(METADATA_FORM_REGISTRY[type])`, then filtered on three predicates in turn — root node is `object` (not a union), liveness `status === 'live'` read from `packages/spec/liveness/TYPE.json`, and the property node peeled of wrappers ONLY (never of `array` / `record`, which would make a list read scalar) resolving to `string` / `number` / `boolean` / `enum`. | stage | reading | |---|---| | top-level zod-only, ADR-0010 overlay skipped | **142** | | … object-rooted | **94** (`view` is union-rooted and contributes the other **48**) | | … graded `live` | **87** | | … resolving to a scalar node | **49** | | the same probe, after this change | **4** — exactly the four left out | **142 is the card's 274 with the overlay taken off** (274 − 132 = 142), so the census's own arithmetic reproduces; **49 is not 47**, and that is the finding the re-derivation was asked for. ⛔ No `view` key is in the population, as the card required. **Three of the five examples the card names are not in it**, which is the same drift measured from the other side: - `object.nameField` ✅ and `object.ownership` ✅ — both landed here. - `field.precision` — **already offered** (`field.form.ts`), so it is not zod-only today. - `action.shortcutKey` — **no such key anywhere in the repository.** The nearest thing is `action.shortcut`, removed with its form input by the 2026-07 #3896 audit close-out in `@objectstack/spec` 17.0.0 — the tombstone at `action.zod.ts:1424-1438` cites no ADR. ADR-0049 is the doctrine; #3896 is the act. - `permission.license` — **not declared** by `PermissionSetSchema` and not a row in `packages/spec/liveness/permission.json`. ## The four keys deliberately left out Each would be a trap rather than an offer. All four are **recorded reasons**, which is sibling card #19333's bucket — ⛔ nothing here writes a ledger row for them. | key | why no control | |---|---| | `object.displayNameField` | `[DEPRECATED → nameField]`. Its canonical replacement lands here; offering the alias beside it teaches the retired spelling. | | `app._unpublished` | the schema's own text says `Never authored` — a machine-managed publish gate written by AI materialization and cleared by publish-drafts. | | `field.system` | the auto-injected-column marker the platform stamps (`applySystemFields`, the search companion). It is read on the **write** path — the record validator skips required and multi-value checks for a flagged column — so a control lets an author assert a false provenance that silently disables validation for that field. | | `field.format` | ONE `z.string()` key carrying THREE value vocabularies. The engine reads it as an autonumber pattern (`resolveAutonumberFormat`, `autonumber-format.ts:196-202`, called from `engine.ts:5043-5051`); objectui reads it as a date display style, `short` / `relative`; the published `describe` names a third, `Format string (e.g. email, phone)`, that nothing measured honours. No help text can be written for a key whose vocabulary is not yet ruled. | ## The judgement in each row The control follows the scalar type; the copy states **what the runtime enforces, including what ABSENCE resolves to** — 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; `action.requiresFeature` says the key is lowered into `visible` at parse time and stripped, so no downstream consumer sees it. **Nineteen rows carry a `visibleWhen` MEANINGFULNESS gate** — not a parse gate. `FieldSchema` accepts every one of these keys whatever the sibling value is, but only some field types, page kinds or action operations ever read them, and offering a knob the runtime does not deliver is what Prime Directive #10 forbids. Fifteen are mirrored from the same key's row in the object designer's quick-add grid, so the two authoring surfaces cannot disagree about when a knob applies. **Three enums carry no inline `options` list, and that is a constraint rather than a preference.** `FormSelectOptionSchema.value` is a system identifier (`^[a-z][a-z0-9_.]*$`), so `object.managedBy`'s four hyphenated members, `action.openIn`'s `new-tab` and `action.execution`'s `perRecord` **cannot be spelled as option values at all** — `defineForm` threw `invalid_format` at module load when they were. Those three derive their enum from the served JSON Schema, which carries every member verbatim, and the meanings ride the help text. See Acceptance notes. ## The i18n half — an EXPANSION of the declared file surface, stated here The claim declared `packages/spec/src/**/*.form.ts`. Six further files changed, and the expansion is mechanically forced: `os i18n extract` walks `METADATA_FORM_REGISTRY`, so a new row moves the generated catalogs and `pnpm check:i18n` reds without them. - **90 new leaves** (45 rows × label + helpText) in the four `metadata-forms.generated.ts` bundles, regenerated with `pnpm i18n:extract`. - **All 90 are AUTHORED in `zh-CN`, `ja-JP` and `es-ES`, not left as extractor fills.** Those three catalogs are ratcheted: a leaf that reads its `en` source and is neither decided nor deferred is red on the day it lands. Verified from the provenance side too — after re-extracting, **0 of the 90** keys remains in any locale's `source-hashes.generated.ts`, which is the table that holds an entry exactly while a leaf is still a byte copy of its source. - Machine tokens are kept verbatim in every locale: enum members, `params._selectedIds`, the `{0000}` and `{recordId}` placeholders, `sys_permission_set.description`. - **Three measured pins moved with the forms, each re-taken rather than adjusted to fit:** the object form's collapsed-section population (45 → 55, `advanced` 36 → 46), its open-section population (94 → 96), and the catalog-wide translated-label control (538 → 583, which is +45 in every locale — exactly the rows that landed). - One more in `packages/lint`: the shipped-form predicate census (53 → 72) and its `==`/`!=` literal half (41 → 52). **Measured, not inferred from the delta** — the `*.form.ts` corpus was differenced against the merge base `5ce3705052` by form/field/source: **19 entries added, 0 removed**, and the eight that do not reach the second census are seven `in`-list gates plus one comparison against a bare `true`. ## Verification Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on a tree at `origin/main` (the stale-tree warning was cleared by merging `8f30c188a` first), every command run with its exit code captured **before any pipe**, then reconciled: ``` Run reconciliation — 85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 85 derived famil(ies) accounted for — 85 run, 0 NOT-MEASURED (a DERIVED zero — all 85 recorded an exit code and none of them is 3). ``` Two of the 85 exited **3** on first pass — `check:dual-build-cjs-loads` and `check:type-check-debt`, both `PREREQUISITE NOT MET` with no `dist` — which measures nothing and is neither a pass nor a finding. Both were re-run after the prerequisite build and both are green; the recorded codes are those readings. Suites, all at `3e453d8da`: | package | reading | |---|---| | `@objectstack/spec` | 512 files / **14 953 passed**, 1 todo | | `@objectstack/platform-objects` | 54 files / **883 passed** | | `@objectstack/lint` | 108 files / **4 111 passed** | | `@objectstack/objectql` (consumer — `getMetaTypes` serves this registry) | 303 files / **5 050 passed** | | `@objectstack/rest` (consumer — the `/meta/types` payload) | 194 files / **3 265 passed**, 1 skipped | | `@objectstack/cli` unit tier (consumer — the i18n extractor walks this registry) | 222 files / **3 141 passed** | `turbo run typecheck` over spec + platform-objects + lint: 10/10 tasks green. `pnpm --filter @objectstack/spec run check:generated`: all 15 generated artifacts up to date, working tree clean afterwards. Repo-wide `pnpm lint` (`eslint . --no-inline-config`) run **whole, not narrowed**: exit 0. The reconciliation gate itself and `metadata-form-declared-rows.pin.test.ts` are green throughout, and the `field.relatedListFilter` / `object.validations` rows they pin are untouched. ## What this deliberately does NOT land ⛔ **The gate that would notice a missing row.** The top-level `zodOnly` direction 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 refused in as many words. This card lands offers; the assertion is a separate card, and landing it without the rows is precisely what was refused. ## Acceptance notes Findings met on the way, ⛔ none fixed here, ⛔ none filed by this seat: 1. **`object.form.ts` offers two formula return types the schema refuses.** The quick-add grid's `fields.returnType` row lists `text`, `number`, `boolean`, `date`, `datetime`, `currency`; `FieldSchema.returnType` is `z.enum(['number','text','boolean','date'])`. Authoring `datetime` or `currency` from the object designer writes a value the parse rejects. The `field.form.ts` row added here lists the four declared members only. `Seam: spec:FieldSchema.returnType → renderer:object.form.ts fields.returnType`. Dedupe words: `returnType option datetime currency` · `form option refused enum member` · `quick-add grid returnType drift`. 2. **The form face cannot express an enum whose members carry a hyphen or a capital.** `FormSelectOptionSchema.value` is a system identifier (`^[a-z][a-z0-9_.]*$`), so `system-data`, `engine-owned`, `append-only`, `better-auth`, `new-tab` and `perRecord` are unspellable as option values — measured, not inferred: `defineForm` threw `invalid_format` on `options[2].value` at module load in this worktree. The workaround used here (derive the enum, put the meanings in help text) works, but a form author who does not know the bound writes an options list that fails at import. `Seam: spec:FormSelectOptionSchema.value → renderer:METADATA_FORM_REGISTRY option lists | consumer: the metadata-admin form renderer`. Dedupe words: `FormSelectOptionSchema value system identifier` · `option value hyphen refused` · `enum member unspellable form option`. 3. **`field.format` is one key with three value vocabularies, and its `describe` names the one with no consumer.** The ledger row is `{"status":"live","evidence":"packages/objectql/src/engine.ts"}` and that evidence **carries**: `engine.ts:5043-5051` resolves every `type: 'autonumber'` field through `resolveAutonumberFormat` (`packages/spec/src/data/autonumber-format.ts:196-202`), which reads `field.format` as the shorthand for the autonumber pattern when `autonumberFormat` is absent. A second consumer reads the same key differently: objectui's date/datetime cell honours `short` / `relative` (`packages/core/src/utils/dataset-format.ts`, pinned by `datetimeCell.formatVocabulary-8853.test.tsx` and `DetailSection.dueLikeReachesTheCell-9729.test.tsx`). The declared `Format string (e.g. email, phone)` is honoured by neither, so an author who follows the describe on an autonumber field renders the literal `email` as their pattern. This is why the key gets no row here. Dedupe words: `field.format three vocabularies` · `autonumber format shorthand` · `describe names a vocabulary no consumer honours`. 4. `page.form.ts`'s existing `kind` row says `full or slotted` while the derived enum offers five members (`full`, `slotted`, `html`, `react`, `jsx`) — an under-description of an existing row, noted only. No card: an omitted member is not a reproducible defect. 5. `object.tenancy`, `app.contextSelectors` and `action.onSuccess` carry a liveness row with **no `status` field** at all. None is a scalar, so none is in this bucket; noted because the probe had to classify them and a status-less row resolves to no verdict. Items 1-3 are handed to the filing seat with their dedupe words; ⛔ this seat opened no issue. `field.system`, `app._unpublished` and `object.displayNameField` are ⛔ not new cards — they are inside sibling #19333's scope (recorded reasons) and are named there. ## 维护者速读(草稿) **改了什么。** 45 个元数据字段原本在 schema 里已声明、运行时也确实在读,却没有任何表单控件,作者只能去 Source 标签页手写 JSON。这次给每个字段补上一行表单控件,并把配套的四语文案一次写全。 **为什么改。** 手写 JSON 没有任何校验提示:写错一个相邻键名,要等运行时拒收才知道。补上控件等于把这批能力从"藏着"变成"能点",也让 AI 代写元数据时读到的是准确的字段说明,而不是自己猜。 **风险与代价(含回滚)。** 不动任何 schema 的接受集合,写进去的数据形状一个字节都没变,因此风险面只在"界面上多了几行"。有 4 个键故意没做控件:两个是平台自己盖章、作者不该碰的,一个是已废弃的旧别名,还有一个的官方说明和唯一能查证的消费方互相矛盾——与其猜一个说法,不如先不给入口。回滚就是回退本 PR,界面回到今天的样子,没有数据迁移。 **席位意见。** 本席(`domain:spec` 执行席位)已逐条复核,⛔ 不是按开发的报告核的,是按 diff 和 `origin/main` 核的: - **45 行是数出来的**。十个 `*.form.ts` 里按 `field:` 键计数:1+1+2+19+6+1+4+9+1+1 = 45。 - **90 条文案也是数出来的**。每个语言包各 45 条 `label:` 与 45 条 `helpText:`,+180 行 = 45×4,四个语言包完全一致;`source-hashes.generated.ts` 按声明不在 diff 里。 - **文件面的扩张对得上**。18 个文件 = 10 个申报面内的 `*.form.ts` + 1 个 changeset + 7 个被机械强制的(四个语言包、两个 platform-objects 铉子、一个 lint 铉子)。 - **卡面举的五个例子里有三个不成立,本席逐个复测过**:`field.precision` 今天已经有控件(`field.form.ts:73`);`shortcutKey` 全仓 0 处;`permission.license` 既不在 `permission.zod.ts`,也不在 `liveness/permission.json`。 - **文案口径抽核过**。`ownership`、`sharingModel`、`undoable`、`app.hidden`、`nameField` 五处,逐条对回声明它的 `.describe()`,一致;其中 `sharingModel` 的「缺省 = private」带了「自定义对象」这个限定词,是对的 —— 系统对象缺省解为 public。 - **退回过一处**。`object-collapsed-sections-echo-decisions.test.ts` 的注释把 2 条文案错记在本卡名下(本卡实际带来 10 条,不是 12 条;那 2 条的缺口在本卡之前就已存在)。已退回修正,断言值一个没动。 - **一处由本席改写**。上面 `field.format` 一行与验收笔记第 3 条,原先写「台账里引的证据文件根本没读这个键」—— 本席实测下这句是错的:引擎确实读它(当作 autonumber 的格式简写)。不给控件的结论不变,而且更站得住:同一个键今天背着三套取值词汇,而官方说明写的那一套没有任何消费方。changeset 里的同一句话正由开发改(它会发到 npm)。 对你要确认的两点,本席的建议是两点都可以收:① 不做控件的四个键里,`field.system` 那一个尤其值得保留 —— 给它控件等于让作者自称“这是系统列”,而记录校验会对系统列跳过必填与多值检查,等于多一个静默关掉校验的开关;② 文案口径写“运行时到底怎么做”、并且把“缺省会解成什么”写进去,是作者从枚举值上读不到的那一半,值得定为默认风格。 席位复核写于 2026-09-22T06:39Z;此前的文档漂移提醒已单独回执。 **你要做的。** 确认两点:① 4 个不做控件的键,理由是否认可;② 这批控件的文案口径(写"运行时到底会怎么做",而不是复述字段名)是否就是你要的默认风格。确认后按常规流程合入即可。 --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b80461 commit 408ca2e

18 files changed

Lines changed: 965 additions & 7 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
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.
7+
8+
**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.
9+
10+
**Four keys are deliberately still unoffered**, each because a control for it would be an authoring trap rather than an offer:
11+
12+
- `object.displayNameField` — `[DEPRECATED → nameField]`. Its canonical replacement `nameField` lands here; offering the alias beside it would teach an author the retired spelling.
13+
- `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.
14+
- `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.
15+
- `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.
16+
17+
**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.
18+
19+
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.
20+
21+
⛔ **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.
22+
23+
⛔ **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.

‎packages/lint/src/validate-predicate-path-refs.test.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,27 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
571571
// `field :: relatedListFilter :: data.type in ['lookup','master_detail']`
572572
// — and NONE was removed. The same card's second row, `object.validations`,
573573
// carries no predicate at all, so it does not enter this census.
574-
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(53);
574+
// It is 72 today, and this one is an ADDITION of NINETEEN: #19331 gave the
575+
// 45 declared-but-unoffered object-rooted live scalar keys a form row each,
576+
// and nineteen of those rows carry a MEANINGFULNESS gate — the schema
577+
// accepts each key whatever the sibling value is, but only some field
578+
// types, page kinds or action operations ever read it, and offering a knob
579+
// the runtime does not deliver is what Prime Directive #10 forbids. Fifteen
580+
// are `field.form.ts` rows mirrored from the same key's row in the object
581+
// designer's quick-add grid, so the two surfaces cannot disagree about when
582+
// a knob applies; the other four are `page :: source`, `action :: openIn`,
583+
// `action :: newTabUrl` and `action :: undoable`.
584+
// Measured rather than inferred from the delta: the shipped `*.form.ts`
585+
// corpus was differenced against the merge base `5ce3705052` by
586+
// `<form>::<field>::<source>`. Exactly nineteen entries were added and NONE
587+
// was removed — `field :: ackPlaintextMasking | allowCreate |
588+
// autonumberFormat | descriptionField | dimensions | displayField |
589+
// inlineAmountField | inlineTitle | language | lookupPageSize | maxSize |
590+
// referenceVia | relatedListTitle | returnType | step`, plus
591+
// `page :: source`, `action :: openIn`, `action :: newTabUrl` and
592+
// `action :: undoable`. The other twenty-six rows that card landed carry no
593+
// predicate at all, so they do not enter this census.
594+
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(72);
575595

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

656685
const rhsFindings = validatePredicatePathRefs(corrupted)
657686
.filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);

0 commit comments

Comments
 (0)