Skip to content

Commit d78a0bd

Browse files
docs(spec): field.useGrouping is live at the objectui pin, and its docblock describes the heuristic the renderer uses (#20908)
Fixes #20313 Clause-②: no `field.useGrouping` moves `planned` → `live` in the liveness ledger, citing the objectui reader at this repo's `.objectui-sha` pin, and the key's JSDoc stops describing a grouping heuristic the renderer does not use. Six files, the claim's surface as the seat amended it for the patch round (claim `5915464335`; see *Patch round* below): - `packages/spec/liveness/field.json`: the `useGrouping` row only. `status: live`, `verifiedAt: 2026-09-30`, `evidenceScope: cross-repo`, `evidence` = the reader, `producer` = the seven relay sites, and a rewritten `note` that no longer says the consumer is in flight. - `packages/spec/liveness/state-counts/field.md`: regenerated by `pnpm --filter @objectstack/spec gen:liveness-counts`, `live 91 → 92`, `planned 2 → 1`. - `packages/spec/src/data/field.zod.ts`: the JSDoc above `useGrouping` only. The `.describe()` string was true and is unchanged, so no generated file moves (`check:generated`: all 15 artifacts up to date). - `.changeset/20313-use-grouping-live.md`: one `@objectstack/spec` patch. The ledger and `src/**/*.zod.ts` both ship in the tarball. The two test files below do not ship, so the changeset is unchanged by the patch round. - `packages/spec/scripts/liveness/check-liveness.test.ts` (patch round): the planned missing-file case makes its own `planned` row, and the typo fixture's comment says what holds. - `packages/spec/src/data/field.test.ts` (patch round): the comment at `:843-846` stops repeating the false closing sentence. ## Reader citations: objectui at the pin `db11afd4967cd9d39381c5e21dc2deec9d706204` The pin carries the landing. REST `compare ae582b70a...db11afd4967c` on objectui answers `ahead`, `ahead_by 21`, `behind_by 0` (read 2026-09-30). The local objectui clone is shallow and lacks `ae582b70a`, so no local ancestry reading is claimed. | ledger field | objectui `file#symbol` | line at the pin | what it does | |:--|:--|:--|:--| | `evidence` | `packages/core/src/utils/number-display.ts#shouldGroupDisplayNumber` | `:200` (authored boolean returns `:209`, currency `:212`, `scale !== 0` `:214`) | the one grouping decision; an authored boolean answers first | | `evidence` | `packages/core/src/utils/number-display.ts#formatDisplayNumber` | `:227` (`:263-266`) | spells the decision for `Intl.NumberFormat`: `useGrouping: false`, or `true` when authored `true` | | `evidence` | `packages/fields/src/index.tsx#NumberCellRenderer` | `:770` (reads `:810-812`, passes `:819`) | reads `field.useGrouping`, booleans only | | `producer` | `packages/plugin-grid/src/ObjectGrid.tsx#generateColumns` | `:3357`, `:3586`, `:3761` | copies it beside `scale` on all three column paths | | `producer` | `packages/plugin-detail/src/fieldEnrichment.ts#ENRICHED_FIELD_METADATA_KEYS` | `:208` | in the key list copied onto a detail-view field | | `producer` | `packages/plugin-detail/src/RecordDetailPanel.tsx#buildRecordDetailFields` | `:171` | relays it beside `scale` | | `producer` | `packages/plugin-detail/src/RelatedList.tsx#effectiveColumns` | `:1562` | relays it beside `scale` | | `producer` | `packages/plugin-list/src/ObjectGallery.tsx#ObjectGallery` | `:376` | relays it beside `scale` | These are the landing note's (`5890301145`) readers and seven relay sites, each re-located at the pin. The relay sites go in `producer` per the README's table: the cell reads field metadata those sites build, never the object definition. The gate attributes every one of these paths to objectui, so it counts them and never resolves them. `scanEvidence` on the new row returns zero local paths: 2 foreign in `evidence`, 5 in `producer`. ## The docblock: what was false at the pin, and what it says now The dispatch named two false sentences. A third one was false too, and it is changed by tense only. 1. **The heuristic.** It said `scale: 0` with no upper bound keeps grouping and that a small bounded integer range drops it. The pin's heuristic never reads a bound: a declared `scale: 0` drops grouping, and any other `scale`, or none, keeps it. It now says so, keeping `objectui#4033`, which answers 200 and is the issue that introduced the heuristic. 2. **The closing sentence.** It said no default is declared "until the renderer half of this contract (objectui#4033) lands and retires the interim heuristic". The renderer half landed as objectui `ae582b70a`, and it keeps the heuristic for an absent key rather than retiring it. The sentence now says that and names objectui's `shouldGroupDisplayNumber`. No tracker number is added. 3. **The motivation sentence** said console renderers "construct `Intl.NumberFormat` with grouping unconditionally ON", so a `scale: 0` year "renders `2,026` everywhere it is shown". That is false at the pin, where the same field renders `2026`. Only the three verbs change: constructed, rendered, was shown. The rest of the docblock is untouched, its other tracker numbers included (#20234 owns those). ## Controls Every reading below is objectstack at this branch, base `33b6e8bece`, and objectui at the pin. - **Before the flip** (`33b6e8bece`, untouched tree): `pnpm --filter @objectstack/spec check:liveness` exit 0, `field 94 classified (live 91, planned 2, dead 1)`, total `967 live · 21 planned`. - **Flip, before the regeneration:** exit 1, `packages/spec/liveness/state-counts/field.md is STALE`. - **After** (`b842de02e0`): exit 0, `field 94 classified (live 92, planned 1, dead 1)`, total `968 live · 20 planned`. The count moved by exactly one. The success line still claims every repo-local evidence path resolves (664/664). Attributed-to-another-repo count 228 → 230, rows citing a producer 118 → 119, cross-repo scope 142 → 143, `verifiedAt` 652 → 653. - **Ablation** (`node scripts/ablation-replace.mjs`, wrap mode, over the committed `b842de02e0`): the anchor `; objectui: packages/fields/src/index.tsx#NumberCellRenderer` becomes `; packages/fields/src/index.tsx#NumberCellRenderer`. That drops the realm marker, which is the realistic way this row rots. The anchor count went 1 → 0 and the blob went `ec2da038770a → 40c4a5c2ec52`. `check:liveness` exited 1 with `✗ 1 'live' / 'planned' / 'experimental' / 'live-elsewhere' entr(ies) cite a file that is missing from THIS repo: field/useGrouping → packages/fields/src/index.tsx`. Restored: blob after = blob at HEAD = `ec2da038770a`, `git diff HEAD` empty, `git status` clean. A re-run is exit 0 with `live 92, planned 1`. ## Tests and gates, first round (at `b842de02e0`) - `pnpm --filter @objectstack/spec build`: exit 0, and it moved no tracked file. - `check:generated`: exit 0, all 15 artifacts up to date. - spec `vitest --project local`: **582 files / 17161 tests passed** (1 todo). - spec `test:repo`: **46 files / 813 tests passed**. - spec `typecheck` (tsc, scripts, test layer): exit 0. - `node scripts/pm/dispatch-gates.mjs` derived 80 commands, all run, exit codes captured before any pipe. `--ran` reconciliation: `80 derived, 79 run, 1 NOT-MEASURED, 0 UNRUN`. The exceptions: - `check:dual-build-cjs-loads`: exit 3, **NOT MEASURED** (`PREREQUISITE NOT MET`: it needs the built output of 86 packages, and this worktree built the spec, formula, lint and objectql closures only). CI builds the whole tree. - `check:platform-checklist`: exit 1 with 2 problems, `coverage.json · picklist: UNCLASSIFIED` and `areas/identity-auth.json … auth-plugin.ts#twoFactor`. **Pre-existing:** the same run on a detached checkout of the base `33b6e8bece` exits 1 with the identical two problems. Neither is touched here. - `check:doc-formula-expressions` and `check:lean-entry-closure` first exited 3 (prerequisites not built). After `turbo run build` of the formula, lint and objectql closures, both exit 0. - **Lint, a declared narrowing:** eslint `--no-inline-config --format json` over the 4 changed files returns 4 results. `field.zod.ts` has 0 errors and 0 warnings. For the other three, eslint's own output is "File ignored because no matching configuration was supplied". `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move the verdict on any untouched file. ## Patch round (the seat's ruling on the report's two open questions) Head `f06c26f536`. It holds three commits after `b842de02e0`: - `8c1fa09815`, the patch itself. - `1608b8857f`, a merge of `origin/main` at `9509ea106a`. `dispatch-gates` flagged the tree as stale on a family file (`scripts/pm/check-governed-queue-guard.mjs`), so main was merged. The merge brings nothing into `packages/spec`: `git diff 8c1fa09 1608b88 -- packages/spec` is empty. - `f06c26f536`, a three-line correction to one comment. **`check-liveness.test.ts`, the planned missing-file case (`:403-426`).** It no longer relies on the shipped ledger grading `field.useGrouping` `planned`. The case now does four things in the copy: 1. sets the copied row to `planned`; 2. moves the copy's count shard with it, using the file's existing `moveCount`; 3. writes the rotted pointer; 4. reads the row back as its control: `expect(readRow(...).status).toBe('planned')`. `setStatus` moves verbatim from the typo block to module scope, and `readRow` is new. - **The copied row keeps its `producer`, `evidenceScope` and `verifiedAt`,** because none of them trips anything on a `planned` row. Measured on a scratch copy: - status `planned` alone: exit 1, and the only finding is `state-counts/field.md is STALE`; - status `planned` plus the count move: exit 0. So the rotted pointer stays the run's only cause. - **Control: an ablation** through `scripts/ablation-replace.mjs --delete`, wrap mode, over the committed `8c1fa09815`. - It deleted `setStatus(root, 'field', 'useGrouping', 'planned');` (anchor 1 → 0, blob `06861b68122f → 27ad6cf2ec7b`). - The case went red on its control, `AssertionError: expected 'live' to be 'planned'`, in 19 ms and before the gate was spawned. The `live` and `experimental` siblings stayed green. - Restored: blob = HEAD `06861b68122f`, and `git diff HEAD` is empty. - Unmutated, the three cases pass by name. - The mutation is in the test file itself, which vitest runs from source, so there is no `dist/` leg. - `f06c26f536` changes only a comment in another block of this file. **The typo fixture's comment (`:934-939`)** now states what holds. This was read from the gate, not assumed: - `check-liveness.mts:886` scans `evidence` only for a status in `EVIDENCE_SCANNED_STATUSES`, and `planed` is not one of them. - `:880-885` resolves `producer` at any status, but every path the row's `producer` cites is objectui-attributed. `scanEvidence` returns zero local paths, so none is resolved against this checkout. - `producer.mts` fails `producer` or `evidenceScope` only when the value is malformed. **`field.test.ts:843-846`** now says the renderer half keeps the interim heuristic for an absent key, and names objectui's `shouldGroupDisplayNumber`. It is the same wording class as JSDoc sentence (b). **The changeset is unchanged.** Neither test file ships: `files[]` carries `src/**/*.zod.ts`, never `*.test.ts`, and nothing under `scripts/`. **Readings.** At `f06c26f536` unless noted: - `check:liveness`: exit 0, `field 94 classified (live 92, planned 1, dead 1)`. - The two changed test files: 2 files / 332 tests passed. The same count also at `8c1fa09815` and `1608b8857f`. - spec `typecheck`: exit 0. The test-layer ledger is unchanged at 52 files / 249 errors / 137 signatures. - spec `test:repo`: 46 files / 813 tests passed at `1608b8857f` and at `8c1fa09815`. It was not re-run at `f06c26f536`: that commit changes only three comment lines in a `local`-project test file. - spec `vitest --project local`: 582 files / 17161 tests passed at `8c1fa09815`. The spec tree is byte-identical at `1608b8857f`. - Gates: `dispatch-gates --commands` derived 87 commands. The test-file kind adds `check:engine-double-contract`, `check:objectql-double-limit`, `check:pm-governed-merges`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. All 87 were run after a full `turbo run build` of `./packages/*` and `./packages/*/*`, 71/71 tasks. - `--ran` reconciliation: `87 derived, 87 run, 0 NOT-MEASURED, 0 UNRUN`. - 86 exited 0, including `check:dual-build-cjs-loads` (not measured in the first round) and `check:type-check-debt` ("none above its recorded number"). - `check:platform-checklist` exits 1 with the same two pre-existing problems measured on the base in the first round. - Lint: eslint `--no-inline-config --format json` over the 6 changed files. The 3 it lints (`check-liveness.test.ts`, `field.test.ts`, `field.zod.ts`) have 0 errors and 0 warnings. The other 3 report "no matching configuration". Type-aware linting is never enabled. ## Acceptance notes Findings outside the claim's file surface as first delivered. Two are folded into the patch round; the rest are untouched. 1. **Folded in the patch round:** the `check-liveness.test.ts` fixtures, which this flip had left driving a `live` row. See *Patch round*. 2. **`packages/spec/src/system/metadata-form-zod-reconciliation.test.ts:387-393`**, the `field` / `useGrouping` omit row, says "declared, not enforced yet — liveness verdict `planned` (the renderer read side … is not landed)". It is stale after this flip, by the file's own rule at `:307-309`: once the key is enforced, "delete it and decide the offer then — that decision belongs to the enforcement". The seat ruled the offer: offer it. **Carrier: the seat's follow-up card, filed at ACCEPT.** That card deletes the omit row, adds a `useGrouping` row on `field.form.ts` for the number type, and regenerates the catalogs the form rows feed. Neither file is touched here. 3. **Folded in the patch round:** `packages/spec/src/data/field.test.ts` repeated the false closing sentence. See *Patch round*. 4. **objectui `packages/plugin-form`** (`deriveMasterDetail.ts:271`, `:323`; `sectionFields.ts:409`) copies `scale` without `useGrouping`. This is a read-only inference, not reproduced, and it may be only an input widget. The ledger's CAVEAT limits the `live` claim to the surfaces `producer` names. Carrier: none. 5. **The one-word rider offered in `5915472266`** ("supplies" at `field.zod.ts` 2649-2650) is not taken, because it is outside the claim's surface. Its pointer names #20827 as the carrier. --- _Generated by [Claude Code](https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d0b9cd commit d78a0bd

6 files changed

Lines changed: 70 additions & 27 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The `field` liveness ledger grades `useGrouping` `live`, and the key's docblock stops describing a grouping heuristic the renderer does not use
6+
7+
Clause-②: no
8+
9+
A number field's authored `useGrouping` is honoured by the console this release builds against:
10+
an authored `true` or `false` decides whether the value renders with thousands separators, and an
11+
absent key keeps the renderer's interim rule. The `liveness/field.json` row therefore moves from
12+
`planned` to `live`, citing the objectui reader and the sites that carry the key to the number
13+
cell, and `liveness/state-counts/field.md` is regenerated to match. The `FieldSchema.useGrouping`
14+
docblock in `src/data/field.zod.ts` said the interim rule looked at a field's `min` / `max`
15+
bounds, and that the renderer half had not landed; both are corrected: the rule reads only a
16+
declared `scale: 0` (ungrouped) against any other `scale` or none (grouped), and the renderer half
17+
reads an authored value first. Text and ledger only: the schema, its `.describe()` string, every
18+
export and all runtime behaviour are unchanged.

‎packages/spec/liveness/field.json‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,12 @@
194194
"note": "CAVEAT — grid formatting only; DDL never sizes."
195195
},
196196
"useGrouping": {
197-
"status": "planned",
198-
"note": "[#7768] Declared 2026-08-11 — Option A of the card's fork (narrowest shape with measured pull, maintainer veto window open). Maps 1:1 to `Intl.NumberFormat`'s `useGrouping`. The runtime consumer is objectui#4033 (in flight at declaration time): its interim heuristic (`scale === 0 && no currency` ⇒ ungrouped) is explicitly documented at its own definition as \"overridden by the spec presentation hint when it lands\" — i.e. the read side is designed to pick this key up, not a speculative future phase. Not `authorWarn`'d: unlike `externalSharingModel`'s scheduled-phase precedent, an author who sets this today loses nothing and is not misled — the value becomes effective the moment the objectui consumer lands, no re-authoring needed. Re-verify to `live` once #4033 (or its successor) actually reads the key."
197+
"status": "live",
198+
"verifiedAt": "2026-09-30",
199+
"evidenceScope": "cross-repo",
200+
"evidence": "objectui: packages/core/src/utils/number-display.ts#shouldGroupDisplayNumber makes the one grouping decision, and an authored boolean `useGrouping` answers it first, in either direction, so only an ABSENT key reaches the interim heuristic, currency ⇒ grouped, else a declared `scale: 0` ⇒ ungrouped; objectui: packages/core/src/utils/number-display.ts#formatDisplayNumber takes the field's `useGrouping`, asks that decision and spells the answer for `Intl.NumberFormat`, an ungrouped answer as `useGrouping: false` and an authored `true` as `useGrouping: true`, the 1:1 mapping this key declares; objectui: packages/fields/src/index.tsx#NumberCellRenderer reads `field.useGrouping`, booleans only, and hands it to formatDisplayNumber — all three read at objectui @db11afd4967c, which is this repo's `.objectui-sha` pin, so the reader is the one inside the console this repo builds against rather than on objectui main",
201+
"producer": "objectui: packages/plugin-grid/src/ObjectGrid.tsx#generateColumns copies the object definition's `useGrouping` onto the column's field metadata beside `scale` on each of its three column paths; objectui: packages/plugin-detail/src/fieldEnrichment.ts#ENRICHED_FIELD_METADATA_KEYS lists it among the object-metadata keys copied onto a detail-view field; objectui: packages/plugin-detail/src/RecordDetailPanel.tsx#buildRecordDetailFields, packages/plugin-detail/src/RelatedList.tsx#effectiveColumns and packages/plugin-list/src/ObjectGallery.tsx#ObjectGallery relay it beside `scale` the same way — seven hand-copy sites, read at objectui @db11afd4967c, the pin. They are the producer half because the cell reads the field metadata these sites build, never the object definition: a site that dropped the key would leave an authored `false` year reading `2,026` on that surface",
202+
"note": "[#7768] Declared 2026-08-11 as Option A of that card's fork, the narrowest shape with measured pull. Maps 1:1 to `Intl.NumberFormat`'s `useGrouping`. FLIPPED `planned` → `live` 2026-09-30, on the condition this row's earlier note set, that the objectui consumer actually read the key: the renderer half landed as objectui ae582b70a, and objectui's compare `ae582b70a...db11afd4967c` answers ahead 21, behind 0, so it is inside the console this repo pins — measured at the pin, not on objectui main. An authored boolean decides in either direction. An ABSENT key is still the renderer's call: the interim heuristic objectui#4033 introduced stays for exactly that case, and it reads the declared `scale` alone, never `min` or `max` — a declared `scale: 0` with no currency is a discrete integer, a year or an ordinal, and renders ungrouped, while any other number keeps grouping. Never `authorWarn`'d, so the flip drops no warning, and an author who set the key while it was `planned` re-authors nothing. CAVEAT — display only, and only on the surfaces whose field metadata carries the key to the cell: the seven relay sites `producer` names."
199203
},
200204
"reference": {
201205
"status": "live",

‎packages/spec/liveness/state-counts/field.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `field` | 91 | 0 | 0 | 1 | 2 | 94 |
15+
| `field` | 92 | 0 | 0 | 1 | 1 | 94 |

‎packages/spec/scripts/liveness/check-liveness.test.ts‎

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,19 @@ function setChildEvidence(root: string, type: string, prop: string, child: strin
7373
writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`);
7474
}
7575

76+
/** Rewrite one property's `status` in a copied ledger. */
77+
function setStatus(root: string, type: string, prop: string, status: string): void {
78+
const file = path.join(root, `${type}.json`);
79+
const ledger = JSON.parse(readFileSync(file, 'utf8'));
80+
ledger.props[prop].status = status;
81+
writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`);
82+
}
83+
84+
/** One property's row, read back from a copied ledger. */
85+
function readRow(root: string, type: string, prop: string): Record<string, unknown> {
86+
return JSON.parse(readFileSync(path.join(root, `${type}.json`), 'utf8')).props[prop];
87+
}
88+
7689
/**
7790
* The population label the gate renders from `EVIDENCE_SCANNED_STATUSES`
7891
* (#13041). Mirrored here once rather than inlined at each assertion, and the
@@ -388,13 +401,23 @@ describe('check:liveness — the evidence-scan population (#13041)', () => {
388401
});
389402

390403
it('FAILS when a `planned` entry cites a repo-local file that is gone', () => {
391-
// `field.useGrouping` is `planned` and carries no evidence today, so the
392-
// pointer this writes is the only thing that can fail — and the rot is the
393-
// plainest kind, the one the existence check has caught for `live` entries
394-
// since #5623.
404+
// The row is MADE `planned` in the copy rather than found that way. This
405+
// case used to rely on `field.useGrouping` being `planned` in the shipped
406+
// ledger, and when that row went `live` the case kept passing while it drove
407+
// a `live` row — the one leg it exists for went unexercised, silently. The
408+
// row's shipped `producer` and `evidenceScope` cite only objectui-attributed
409+
// paths and trip nothing on a `planned` row, and `moveCount` keeps the copy's
410+
// count shard in step with the flip, so the pointer written below is still
411+
// the run's only cause — and the rot is the plainest kind, the one the
412+
// existence check has caught for `live` entries since #5623.
395413
const root = path.join(tmp, 'planned-missing-file');
396414
cpSync(LEDGERS, root, { recursive: true });
415+
const shipped = String(readRow(root, 'field', 'useGrouping').status);
416+
setStatus(root, 'field', 'useGrouping', 'planned');
417+
if (shipped !== 'planned') moveCount(root, 'field', shipped, 'planned');
397418
setEvidence(root, 'field', 'useGrouping', `${ROTTED} (rotted by the self-test)`);
419+
// The control: the row this run judges IS `planned` in the copy.
420+
expect(readRow(root, 'field', 'useGrouping').status).toBe('planned');
398421

399422
const { status, output } = runGate(root);
400423
expect(status, output).toBe(1);
@@ -904,21 +927,16 @@ describe('check:liveness — an unrecognized ledger `status` (#13083)', () => {
904927
});
905928
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
906929

907-
/** Rewrite one property's `status` in a copied ledger. */
908-
function setStatus(root: string, type: string, prop: string, status: string): void {
909-
const file = path.join(root, `${type}.json`);
910-
const ledger = JSON.parse(readFileSync(file, 'utf8'));
911-
ledger.props[prop].status = status;
912-
writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`);
913-
}
914-
915930
/** A copy of the real ledger root with `field.useGrouping` misspelled. */
916931
function typodRoot(name: string): string {
917932
const root = path.join(tmp, name);
918933
cpSync(LEDGERS, root, { recursive: true });
919-
// `field.useGrouping` is `planned` and carries no evidence, so the misspelling
920-
// is the only thing in the copy that can move a verdict — no evidence-scan
921-
// finding can be confused for it.
934+
// The misspelling is the only thing in the copy that can move a verdict — no
935+
// evidence-scan finding can be confused for it. The gate scans `evidence`
936+
// only for a status in `EVIDENCE_SCANNED_STATUSES`, which `planed` is not,
937+
// and it resolves `producer` at any status, but every path this row's
938+
// `producer` cites is objectui-attributed, so none of them is resolved
939+
// against this checkout.
922940
setStatus(root, 'field', 'useGrouping', 'planed');
923941
return root;
924942
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -841,8 +841,9 @@ describe('FieldSchema', () => {
841841
expect(prop).toBeDefined();
842842
expect(prop.type).toBe('boolean');
843843
// Unlike `autonumberFormat`, this key carries no JSON-Schema `default`
844-
// annotation — there is no renderer-agnostic grouping behavior to declare
845-
// until the objectui half (#4033) retires the interim heuristic.
844+
// annotation — there is no renderer-agnostic grouping behavior to declare:
845+
// an absent key is the renderer's call, and the renderer half (objectui's
846+
// `shouldGroupDisplayNumber`) keeps the interim heuristic for it.
846847
expect(prop.default).toBeUndefined();
847848
expect(js.required ?? []).not.toContain('useGrouping');
848849
});

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,10 +1277,10 @@ export const FieldSchema = lazySchema(() => {
12771277
* Presentation hint (#7768): whether a `number` field renders with digit
12781278
* grouping (`Intl.NumberFormat`'s `useGrouping`, e.g. `2,026` vs `2026`).
12791279
* `scale` was the ONLY presentation-adjacent property `number` had, and it
1280-
* governs decimal places, not grouping — console renderers construct
1280+
* governs decimal places, not grouping — console renderers constructed
12811281
* `Intl.NumberFormat` with grouping unconditionally ON, so an
12821282
* ordinal/identifier integer stored as `Field.number({ scale: 0, min: 1900
1283-
* })` (a year) renders `2,026` everywhere it is shown. Downstream apps hit
1283+
* })` (a year) rendered `2,026` everywhere it was shown. Downstream apps hit
12841284
* this three times (hotcrm-heimao#35/#40/#59) and each time converted the
12851285
* field to `Field.text` to escape the comma — trading away numeric
12861286
* semantics (range validation, sort-as-number, arithmetic) for a display
@@ -1289,9 +1289,10 @@ export const FieldSchema = lazySchema(() => {
12891289
* Three-valued, and the absent case is deliberately NOT "grouping off":
12901290
* - **absent** (default state) — the author has not judged whether this
12911291
* number reads as a quantity or an identifier; the RENDERER decides.
1292-
* Today that is an interim heuristic (objectui#4033, e.g. `scale: 0`
1293-
* + no upper bound reads as a plain count and keeps grouping, a small
1294-
* bounded integer range reads as ordinal-shaped and drops it);
1292+
* Today that is an interim heuristic (objectui#4033: a declared
1293+
* `scale: 0` reads as a discrete integer, a year or an ordinal, and
1294+
* drops grouping, while any other `scale`, or none, keeps it — no
1295+
* bound is read);
12951296
* eventually the locale's own default. Neither contract lives here —
12961297
* this key only carries the author's EXPLICIT override when they have
12971298
* one, exactly like `min`/`max`/`scale` carry constraints without
@@ -1308,8 +1309,9 @@ export const FieldSchema = lazySchema(() => {
13081309
* number renderers are expected to pass it straight through. No default is
13091310
* declared here on purpose — unlike `autonumberFormat`'s JSON-Schema
13101311
* `default` annotation, there is no single grouping behavior every
1311-
* `number` field should present until the renderer half of this contract
1312-
* (objectui#4033) lands and retires the interim heuristic.
1312+
* `number` field should present: the renderer half of this contract has
1313+
* landed (objectui's `shouldGroupDisplayNumber` reads an authored value
1314+
* first) and keeps the interim heuristic for an absent key.
13131315
*/
13141316
useGrouping: z.boolean().optional().describe('Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`\'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on.'),
13151317

0 commit comments

Comments
 (0)