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
18 changes: 18 additions & 0 deletions .changeset/20313-use-grouping-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': patch
---

The `field` liveness ledger grades `useGrouping` `live`, and the key's docblock stops describing a grouping heuristic the renderer does not use

Clause-②: no

A number field's authored `useGrouping` is honoured by the console this release builds against:
an authored `true` or `false` decides whether the value renders with thousands separators, and an
absent key keeps the renderer's interim rule. The `liveness/field.json` row therefore moves from
`planned` to `live`, citing the objectui reader and the sites that carry the key to the number
cell, and `liveness/state-counts/field.md` is regenerated to match. The `FieldSchema.useGrouping`
docblock in `src/data/field.zod.ts` said the interim rule looked at a field's `min` / `max`
bounds, and that the renderer half had not landed; both are corrected: the rule reads only a
declared `scale: 0` (ungrouped) against any other `scale` or none (grouped), and the renderer half
reads an authored value first. Text and ledger only: the schema, its `.describe()` string, every
export and all runtime behaviour are unchanged.
8 changes: 6 additions & 2 deletions packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,12 @@
"note": "CAVEAT — grid formatting only; DDL never sizes."
},
"useGrouping": {
"status": "planned",
"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."
"status": "live",
"verifiedAt": "2026-09-30",
"evidenceScope": "cross-repo",
"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",
"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",
"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."
},
"reference": {
"status": "live",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/field.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.

| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
| `field` | 91 | 0 | 0 | 1 | 2 | 94 |
| `field` | 92 | 0 | 0 | 1 | 1 | 94 |
48 changes: 33 additions & 15 deletions packages/spec/scripts/liveness/check-liveness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ function setChildEvidence(root: string, type: string, prop: string, child: strin
writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`);
}

/** Rewrite one property's `status` in a copied ledger. */
function setStatus(root: string, type: string, prop: string, status: string): void {
const file = path.join(root, `${type}.json`);
const ledger = JSON.parse(readFileSync(file, 'utf8'));
ledger.props[prop].status = status;
writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`);
}

/** One property's row, read back from a copied ledger. */
function readRow(root: string, type: string, prop: string): Record<string, unknown> {
return JSON.parse(readFileSync(path.join(root, `${type}.json`), 'utf8')).props[prop];
}

/**
* The population label the gate renders from `EVIDENCE_SCANNED_STATUSES`
* (#13041). Mirrored here once rather than inlined at each assertion, and the
Expand Down Expand Up @@ -388,13 +401,23 @@ describe('check:liveness — the evidence-scan population (#13041)', () => {
});

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

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

/** Rewrite one property's `status` in a copied ledger. */
function setStatus(root: string, type: string, prop: string, status: string): void {
const file = path.join(root, `${type}.json`);
const ledger = JSON.parse(readFileSync(file, 'utf8'));
ledger.props[prop].status = status;
writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`);
}

/** A copy of the real ledger root with `field.useGrouping` misspelled. */
function typodRoot(name: string): string {
const root = path.join(tmp, name);
cpSync(LEDGERS, root, { recursive: true });
// `field.useGrouping` is `planned` and carries no evidence, so the misspelling
// is the only thing in the copy that can move a verdict — no evidence-scan
// finding can be confused for it.
// The misspelling is the only thing in the copy that can move a verdict — no
// evidence-scan finding can be confused for it. The gate scans `evidence`
// only for a status in `EVIDENCE_SCANNED_STATUSES`, which `planed` is not,
// and it resolves `producer` at any status, but every path this row's
// `producer` cites is objectui-attributed, so none of them is resolved
// against this checkout.
setStatus(root, 'field', 'useGrouping', 'planed');
return root;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/data/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,8 +841,9 @@ describe('FieldSchema', () => {
expect(prop).toBeDefined();
expect(prop.type).toBe('boolean');
// Unlike `autonumberFormat`, this key carries no JSON-Schema `default`
// annotation — there is no renderer-agnostic grouping behavior to declare
// until the objectui half (#4033) retires the interim heuristic.
// annotation — there is no renderer-agnostic grouping behavior to declare:
// an absent key is the renderer's call, and the renderer half (objectui's
// `shouldGroupDisplayNumber`) keeps the interim heuristic for it.
expect(prop.default).toBeUndefined();
expect(js.required ?? []).not.toContain('useGrouping');
});
Expand Down
16 changes: 9 additions & 7 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1277,10 +1277,10 @@ export const FieldSchema = lazySchema(() => {
* Presentation hint (#7768): whether a `number` field renders with digit
* grouping (`Intl.NumberFormat`'s `useGrouping`, e.g. `2,026` vs `2026`).
* `scale` was the ONLY presentation-adjacent property `number` had, and it
* governs decimal places, not grouping — console renderers construct
* governs decimal places, not grouping — console renderers constructed
* `Intl.NumberFormat` with grouping unconditionally ON, so an
* ordinal/identifier integer stored as `Field.number({ scale: 0, min: 1900
* })` (a year) renders `2,026` everywhere it is shown. Downstream apps hit
* })` (a year) rendered `2,026` everywhere it was shown. Downstream apps hit
* this three times (hotcrm-heimao#35/#40/#59) and each time converted the
* field to `Field.text` to escape the comma — trading away numeric
* semantics (range validation, sort-as-number, arithmetic) for a display
Expand All @@ -1289,9 +1289,10 @@ export const FieldSchema = lazySchema(() => {
* Three-valued, and the absent case is deliberately NOT "grouping off":
* - **absent** (default state) — the author has not judged whether this
* number reads as a quantity or an identifier; the RENDERER decides.
* Today that is an interim heuristic (objectui#4033, e.g. `scale: 0`
* + no upper bound reads as a plain count and keeps grouping, a small
* bounded integer range reads as ordinal-shaped and drops it);
* Today that is an interim heuristic (objectui#4033: a declared
* `scale: 0` reads as a discrete integer, a year or an ordinal, and
* drops grouping, while any other `scale`, or none, keeps it — no
* bound is read);
* eventually the locale's own default. Neither contract lives here —
* this key only carries the author's EXPLICIT override when they have
* one, exactly like `min`/`max`/`scale` carry constraints without
Expand All @@ -1308,8 +1309,9 @@ export const FieldSchema = lazySchema(() => {
* number renderers are expected to pass it straight through. No default is
* declared here on purpose — unlike `autonumberFormat`'s JSON-Schema
* `default` annotation, there is no single grouping behavior every
* `number` field should present until the renderer half of this contract
* (objectui#4033) lands and retires the interim heuristic.
* `number` field should present: the renderer half of this contract has
* landed (objectui's `shouldGroupDisplayNumber` reads an authored value
* first) and keeps the interim heuristic for an absent key.
*/
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.'),

Expand Down
Loading