Skip to content

Commit 31c3996

Browse files
feat(spec): offer useGrouping on the number field form, and delete its stale not-enforced-yet omit row (#20934)
Fixes #20920 Clause-②: no ## What `field.useGrouping` is `live` since `d78a0bda07`, the enforcement carried by #20313: objectui's `shouldGroupDisplayNumber` answers an authored value first. The reconciliation ledger's own rule assigns the offer decision to that enforcement ("Once a key is enforced its row is stale: delete it and decide the offer then"), and its seat ruled **offer it** (`5916202706`, corrected by `5916211005`). The at-tier review `5917470646` on PR #20908 routed the move to this card. This PR carries it out: - `metadata-form-zod-reconciliation.test.ts`: the `field/useGrouping` "declared, not enforced yet" omit row is deleted, not re-reasoned. - `field.form.ts`: one `useGrouping` row beside `scale`, with `scale`'s gate `visibleWhen: "data.type == 'number'"`. - The four `platform-objects` metadata-form catalogs, regenerated with the generator. The two new leaves are authored in `zh-CN`, `ja-JP` and `es-ES`. - Two population pins that the row moves mechanically, and one changeset. | key | form, section | control | the row it copies | gate | |:--|:--|:--|:--|:--| | `field.useGrouping` | `field.form.ts`, Configuration | plain row, no `widget`, no `type` | `field.form.ts` `allowCreate` (the same `z.boolean().optional()` node, no default) | `number` (`scale`'s) | The help text follows the key's `.describe()`: "Digit grouping (thousands separators) in displayed values. Unset: the renderer decides; an untouched switch writes nothing, so it reads off even where the renderer groups. Off: never grouped, e.g. a year or an ID. On: always grouped." The middle clause restates, for this switch, the sentence the object form's `userActions` row carries for the same renderer behaviour (see Acceptance notes). No schema accept set moves and no export changes: `check:api-surface` reports the surface unchanged, and `check:authorable-surface` exits 0. ## The control (dispatch assumption 1) | commit | tree | `metadata-form-zod-reconciliation.test.ts` | |:--|:--|:--| | `699517cfb9` | omit row deleted, no form row | **red**: `field.(root): accepted by the Zod but unauthorable in the form — offer it, or add a root ledger entry that records why it is not offered: expected [ 'useGrouping' ] to deeply equal []`. Tests 1 failed, 75 passed (76). `VERDICT command-exit 1` | | `75773930ed` | form row added | **green**: Tests 76 passed (76). `VERDICT command-exit 0` | At the final head the same file is green inside the full `@objectstack/spec` run below. ## The console renders it (dispatch assumption 4) Read in objectui at the pin `db11afd4967c`, source only, no browser: - The served node is `{ "type": "boolean", "description": … }` (`packages/spec/json-schema/data/Field.json`). - `SchemaForm.tsx` `inferWidget`: the row has no `widget` and no `type`, so the JSON Schema branch runs, and `type === 'boolean'` gives `'switch'`. No name detector fires: `useGrouping` admits no string, so the condition, secret, icon, colour and field-ref detectors all decline. - `resolveFieldFace`: `'switch'` is not a `WIDGETS` key but is in `KNOWN_PASSTHROUGH_WIDGETS`, so the scalar chain runs, and `effectiveType === 'boolean'` gives `{ kind: 'scalar' }`. - `FieldControl`'s boolean branch renders a `Switch` with `checked={!!value}` and writes only from `onCheckedChange`. Nothing is written on mount. - objectui's own `SchemaForm.requiredMarker-10367.test.tsx` renders a bare `{ type: 'boolean' }` property as `role="switch"` with no wiring. The field form's `allowCreate` row takes the same path today. So the row renders with no extra wiring, and the offer is not the ADR-0078 shape in reverse. ## Catalogs (dispatch assumption 3) 1. `node scripts/check-i18n-bundles.mjs --write`, the regenerator AGENTS.md names for all nine bundle sets. Only `platform-objects` moved: the `en` pair, English fills in the three translated locales, and two provenance rows per locale. That output is committed unedited as `b9a8778e0b`. 2. The six translated leaves are written by hand in this catalog's authored vocabulary: "Unset:" is 未设置 / 未設定の場合は / Sin definir, "renderer" is 渲染端 / レンダラー / renderizador, and the untouched-switch clause follows each locale's authored `object.fields.userActions.helpText`. Labels: 千位分隔符 / 桁区切り / Separador de miles. 3. `--write` again. Merge mode kept every value and dropped the provenance rows, so the three `*.source-hashes.generated.ts` files are byte-identical to the base (`7fea903c24`). This is the rule `5757463712` and `f4ce10c89d` read: an extractor fill is a byte copy recorded in the provenance table, and a translated leaf is authored. ## Pins this PR moves Each went red at exactly the predicted number first, and then moved: - `packages/lint/src/validate-predicate-path-refs.test.ts`: the shipped-form predicate census 82 → 83, and its literal-comparison count 57 → 58. Measured against the merge base `d78a0bda07`: the shipped `*.form.ts` corpus adds exactly `field :: useGrouping :: data.type == 'number'` and removes none. - `packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts`: the translated-label positive control 658 → 659, one row label authored in all three locales. `field-panel-echo-decisions.test.ts` did not move: its no-echo rule reads the two new leaves as authored. Every changed file is inside the claim's file surface. ## Changeset `.changeset/20920-use-grouping-form-row.md`: `@objectstack/spec` **minor** and `@objectstack/platform-objects` **patch**, `Clause-②: no`. These are PR #20428's levels for the same shape (form rows plus their catalog leaves). What ships was measured after the build, with a positive control each time: - spec: the new row's text is in `dist/data/index.mjs`, `dist/system/index.js` and their `dist/browser/` twins (`dist` is in `files[]`), beside the control `Number of decimal digits`. The served form payload gains an authoring control, which is a backwards-compatible addition, so the level is minor. - platform-objects: after a rebuild, the new leaves are in `dist/plugin.mjs`, `dist/index.mjs` and `dist/metadata-translations/index.mjs` (`Separador de miles` literally, the zh-CN label as escapes), beside the control `Decimales`. This is catalog content only, so the level is patch. - No accept or refuse outcome moves, so the declaration is `no`. ## Verification, head `841a8734bd` Every build, test and typecheck ran through `scripts/pm/os-verify-lock.sh`. Each reported `VERDICT command-exit 0`, except the three first-red measurements named above. | run | result | |:--|:--| | `pnpm build` (whole workspace) | 72 of 72 tasks successful | | `pnpm --filter @objectstack/spec test` | 583 files passed · 17166 tests passed, 1 todo | | `pnpm --filter @objectstack/spec test:repo` | 47 files passed · 831 tests passed | | `pnpm --filter @objectstack/platform-objects test` | 59 files passed · 948 tests passed | | `pnpm --filter @objectstack/lint test` | 117 files passed · 5438 tests passed | | spec / platform-objects / lint `typecheck` | exit 0 each. `check:test-typecheck` OK at 52/248/136, 1/3/2 and 2/6/2 files/errors/signatures held | | the form-registry readers in other packages: cli unit `i18n-coverage` + `i18n-duplicate-demand`; metadata-protocol `protocol.meta-types-*` (4 files); objectql `protocol-meta-types-rich` | 2 files / 27 tests, 4 / 58, 1 / 5, all passed | | `pnpm --filter @objectstack/spec check:generated` | all 15 generated artifacts up to date | | `pnpm check:i18n` | OK: 9 packages, all bundles in sync, no undeclared authoring keys | | `pnpm check:i18n-stale-fill` | OK: 0 stale-fill leaves, 0 baselined | **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 86 commands at `841a8734bd`. All 86 ran, and each exit code was written to disk before any pipe. `--ran` reports: 86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN, and all 86 exited 0. **Lint, narrowed.** The 8 changed `.ts` files, each matched by eslint's own config (`--print-config` exits 0 for each). The `--format json` output reads 8 files, 0 errors, 0 warnings. Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on a file it does not touch. The repo-wide `pnpm lint` is CI's run. No ablation was run: this PR adds a row and moves population pins, and adds no guard. The reconciliation red/green pair above is the measurement of the delta. `origin/main` is three commits past this branch's base (`67c1b11a20`, `aaad682dbc`, `87847a27ce`). None of them touches a file in this diff, so it was not merged in. ## Acceptance notes - **A two-state control on a three-state key.** The switch cannot show "unset", and cannot put a touched field back to unset: once toggled, an author reaches unset again only in the Source tab. The help text states that the untouched state reads off. The same bound holds for every switch over an optional boolean whose absence is not `false`, the object form's `userActions` entries among them. Nothing is refused or dropped, so no card is owed. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1571aed commit 31c3996

9 files changed

Lines changed: 49 additions & 10 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
Clause-②: no
7+
8+
The field form offers `useGrouping` on `number` fields: one plain boolean row beside `scale`, gated to `number` as `scale` is, whose control copies the field form's `allowCreate` row (the same `z.boolean().optional()` node, no default). The key was declared by `FieldSchema` and graded `live` by the liveness ledger once the console's number display began to answer an authored value first, but no form offered it, so an author's only door was the Source tab. The help text follows the key's own description: unset lets the renderer decide, off never groups (a year or an ID), on always groups. It also says that an untouched switch writes nothing, so it reads off even where the renderer groups.
9+
10+
⛔ **No schema accept set moves and no export changes.** 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 two new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills.

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,13 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
614614
// 81 → 82, `field :: inlineColumns :: data.type == 'master_detail'` added
615615
// and NONE removed. The other three rows and all their sub-rows carry no
616616
// predicate.
617-
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(82);
617+
// It is 83 today, an ADDITION of ONE: the field form's `useGrouping` row,
618+
// offered once the key was enforced, carries `scale`'s gate — the key is a
619+
// display hint for `number` fields only. Measured, not inferred: the
620+
// shipped `*.form.ts` corpus differenced against the merge base
621+
// `d78a0bda07` adds exactly `field :: useGrouping :: data.type == 'number'`
622+
// and removes none.
623+
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(83);
618624

619625
const findings = validatePredicatePathRefs(corrupted);
620626
expect(findings).toHaveLength(predicates);
@@ -713,7 +719,9 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
713719
// It is 57 today: #19332 G2b's one new predicate, `field :: inlineColumns`
714720
// on `data.type == 'master_detail'`, compares against a single-quoted
715721
// literal.
716-
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(57);
722+
// It is 58 today: the field form's `useGrouping` row is gated on
723+
// `data.type == 'number'`, a single-quoted literal comparison.
724+
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(58);
717725

718726
const rhsFindings = validatePredicatePathRefs(corrupted)
719727
.filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
580580
label: "Scale",
581581
helpText: "Number of decimal digits"
582582
},
583+
useGrouping: {
584+
label: "Use Grouping",
585+
helpText: "Digit grouping (thousands separators) in displayed values. Unset: the renderer decides; an untouched switch writes nothing, so it reads off even where the renderer groups. Off: never grouped, e.g. a year or an ID. On: always grouped."
586+
},
583587
currencyConfig: {
584588
label: "Currency Config",
585589
helpText: "Which currency this field is in. Unset: dynamic mode. The stored value is a bare number in either mode."

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
580580
label: "Decimales",
581581
helpText: "Número de dígitos decimales"
582582
},
583+
useGrouping: {
584+
label: "Separador de miles",
585+
helpText: "Agrupación de dígitos (separador de miles) en los valores mostrados. Sin definir: lo decide el renderizador; un interruptor que no se ha tocado no escribe nada, así que aparece desactivado aunque el renderizador agrupe. Desactivado: nunca se agrupa, p. ej., un año o un ID. Activado: siempre se agrupa."
586+
},
583587
currencyConfig: {
584588
label: "Configuración de moneda",
585589
helpText: "En qué moneda está este campo. Sin definir: modo dynamic. En ambos modos el valor almacenado es un número sin más."

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
580580
label: "小数桁",
581581
helpText: "小数部の桁数"
582582
},
583+
useGrouping: {
584+
label: "桁区切り",
585+
helpText: "表示する値の桁区切り(3 桁ごとの区切り記号)。未設定の場合はレンダラーが決めます。一度も操作していないスイッチは何も書き込まないため、レンダラーが区切る場合でもオフと表示されます。オフ:区切りません(例:年や ID)。オン:常に区切ります。"
586+
},
583587
currencyConfig: {
584588
label: "通貨設定",
585589
helpText: "このフィールドの通貨。未設定の場合は dynamic モードです。どちらのモードでも、保存される値は単なる数値です。"

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
11481148
// three locales.
11491149
// 658 with the `picklist` metadata type's display pair, authored in all
11501150
// three locales.
1151-
expect(translated.length, `${locale} positive control`).toBe(658);
1151+
// 659 since the field form offers `useGrouping` on `number` fields: one
1152+
// new row label, authored in all three locales.
1153+
expect(translated.length, `${locale} positive control`).toBe(659);
11521154
}
11531155
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
11541156
// all-three predicate returns 0 while the per-locale one returns 1, so the

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
580580
label: "小数位",
581581
helpText: "小数部分位数"
582582
},
583+
useGrouping: {
584+
label: "千位分隔符",
585+
helpText: "显示值时的数字分组(千位分隔符)。未设置:由渲染端决定;未改动过的开关不写入任何值,所以即使渲染端会分组,它也显示为关闭。关闭:从不分组,例如年份或 ID。开启:始终分组。"
586+
},
583587
currencyConfig: {
584588
label: "货币配置",
585589
helpText: "此字段使用哪种货币。未设置时为 dynamic 模式。两种模式下存储的值都是一个纯数字。"

‎packages/spec/src/data/field.form.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ export const fieldForm = defineForm({
8282
// unit and are declared nowhere.
8383
{ field: 'precision', visibleWhen: "data.type == 'currency' || data.type == 'number'", helpText: 'Total digits' },
8484
{ field: 'scale', visibleWhen: "data.type == 'number'", helpText: 'Number of decimal digits' },
85+
// `useGrouping` is shown once it is enforced (ruling 5916202706: offer
86+
// it) — objectui's `shouldGroupDisplayNumber` answers an authored value
87+
// first, so the order is the `valueDomain` row's: declared = enforced =
88+
// shown. A plain boolean row, the face of the `allowCreate` row below
89+
// (the same `z.boolean().optional()` node, no default): the switch
90+
// writes nothing until toggled, so an untouched field stays unset and
91+
// the renderer decides. It reads off meanwhile, even where the renderer
92+
// groups, and the help text says so in the words of the `object.form.ts`
93+
// `userActions` row. Gated like `scale`, a `number`-only hint.
94+
{ field: 'useGrouping', visibleWhen: "data.type == 'number'", helpText: 'Digit grouping (thousands separators) in displayed values. Unset: the renderer decides; an untouched switch writes nothing, so it reads off even where the renderer groups. Off: never grouped, e.g. a year or an ID. On: always grouped.' },
8595
// #19332 G1b (ruling 5861442317, G1) — `currencyConfig` is a strict object
8696
// with two keys, and both members of its one enum are spellable option
8797
// values, so it takes the `object.form.ts` `access` / `lifecycle` face: a

‎packages/spec/src/system/metadata-form-zod-reconciliation.test.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -384,13 +384,6 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
384384
key: 'externalSharingModel',
385385
why: 'declared, not enforced yet — liveness verdict `planned` (ADR-0090 D11: validated at authoring time only; the audience-aware evaluator branch that would honour it is scheduled, not built). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
386386
},
387-
{
388-
kind: 'omit',
389-
type: 'field',
390-
path: ROOT_PATH,
391-
key: 'useGrouping',
392-
why: 'declared, not enforced yet — liveness verdict `planned` (the renderer read side that maps it onto `Intl.NumberFormat` is not landed). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
393-
},
394387
{
395388
kind: 'omit',
396389
type: 'field',

0 commit comments

Comments
 (0)