From 3973de051c8d81fbd7881d7e4f7b1246bebd611b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 15:40:43 +0000 Subject: [PATCH 1/2] feat(spec): Field.number gains useGrouping presentation hint (#7768) FieldSchema gains an optional `useGrouping: boolean` (Option A, ruled 2026-08-11 on #7768, maintainer veto window open) so an authored number field can opt out of Intl.NumberFormat's digit grouping without losing numeric semantics -- the fix for years (Field.number({ scale: 0, min: 1900 })) rendering as "2,026" that downstream apps have worked around three times by converting to Field.text (hotcrm-heimao#35/#40/#59). No default is declared: absent defers to the renderer (interim heuristic today, locale default eventually -- objectui#4033's contract, not this package's). Threads through Field.number(...) automatically via the existing FieldInput shape, same as scale/min. Also: liveness ledger classifies the key `planned` (objectui#4033 is the pending consumer); authorable-surface/data.json, field.mdx and state-counts.md regenerated to match. --- .changeset/number-field-use-grouping.md | 41 +++++++++++++ content/docs/references/data/field.mdx | 1 + packages/spec/authorable-surface/data.json | 1 + packages/spec/liveness/field.json | 4 ++ packages/spec/liveness/state-counts.md | 4 +- packages/spec/src/data/field.test.ts | 70 ++++++++++++++++++++++ packages/spec/src/data/field.zod.ts | 39 ++++++++++++ 7 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 .changeset/number-field-use-grouping.md diff --git a/.changeset/number-field-use-grouping.md b/.changeset/number-field-use-grouping.md new file mode 100644 index 0000000000..73aed93e93 --- /dev/null +++ b/.changeset/number-field-use-grouping.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `Field.number` gains a `useGrouping` presentation hint (#7768) + +`scale` was the only presentation-adjacent property a `number` field carried, +and it governs decimal places, not digit grouping. Console number renderers +construct `Intl.NumberFormat` with grouping unconditionally ON, so an +ordinal/identifier integer authored as `Field.number({ scale: 0, min: 1900 })` +(e.g. a year) renders `2,026` everywhere it is 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 detail unrelated to the +field's type. + +**New:** `useGrouping?: boolean` on `FieldSchema` (flat, alongside +`precision`/`scale`/`min`/`max`), threaded automatically through +`Field.number(...)` and every other type's builder via the existing +`FieldInput` shape — no builder special-casing needed, the same way +`scale`/`min` travel today. + +Deliberately three-valued and NO default declared: + +- **absent** — the author has not judged whether this integer reads as a + quantity or an identifier; the renderer decides (an interim heuristic today, + the locale's own default eventually — that contract lives in objectui, not + here). +- **`false`** — the author's explicit opt-out: never group this number + (a year, an ID, a zip code). +- **`true`** — the author pins grouping on, overriding the heuristic the + other way. + +Maps 1:1 onto `Intl.NumberFormat`'s `useGrouping`. This is Option A of the +card's fork — the narrowest shape with measured pull — ruled on #7768, +2026-08-11, with the maintainer's veto window open. No `displayFormat` slot, +no other presentation knobs. + +Unblocks objectui#4033, the console renderer half that consumes the explicit +hint and retires the interim heuristic (explicit-hint > heuristic > +locale-default). diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 03d66f5dd3..a05fd42181 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -71,6 +71,7 @@ const result = CurrencyConfigSchema.parse(data); | **scale** | `number` | optional | Decimal places | | **min** | `number` | optional | Minimum value | | **max** | `number` | optional | Maximum value | +| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields (#7768) — 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. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 7e9b4ace97..0e231d6c89 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -415,6 +415,7 @@ "data/Field:trackHistory", "data/Field:type", "data/Field:unique", + "data/Field:useGrouping", "data/Field:visibleWhen", "data/Field:widget", "data/FieldReference:$field", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 0e0dd03f99..e3188c2fa3 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -166,6 +166,10 @@ "status": "live", "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." + }, "reference": { "status": "live", "evidence": "packages/objectql/src/engine.ts:1672", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index e275c3cb2d..d8fe9de0ff 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -28,7 +28,7 @@ for both corollaries. | Type | live | exp | dead | planned | classified | |---|---|---|---|---|---| | `object` | 49 | 0 | 0 | 1 | 50 | -| `field` | 66 | 0 | 0 | 0 | 66 | +| `field` | 66 | 0 | 0 | 1 | 67 | | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 42 | 0 | 2 | 0 | 44 | | `hook` | 18 | 0 | 2 | 0 | 20 | @@ -57,4 +57,4 @@ for both corollaries. | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **776** | **6** | **52** | **5** | **839** | +| **total** | **776** | **6** | **52** | **6** | **840** | diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 688b4c176f..dc9fffe40d 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { FieldSchema, FieldType, @@ -239,6 +240,75 @@ describe('FieldSchema', () => { }); }); + describe('useGrouping — number-field digit-grouping presentation hint (#7768)', () => { + it('accepts an explicit `false` (author opts out of grouping — e.g. a year)', () => { + const yearField: Field = { + name: 'founded_year', + label: 'Founded Year', + type: 'number', + scale: 0, + min: 1900, + useGrouping: false, + }; + const result = FieldSchema.parse(yearField); + expect(result.useGrouping).toBe(false); + }); + + it('accepts an explicit `true` (author pins grouping on)', () => { + const result = FieldSchema.parse({ type: 'number', useGrouping: true }); + expect(result.useGrouping).toBe(true); + }); + + it('is optional — absent stays absent, no default materializes', () => { + const result = FieldSchema.parse({ type: 'number' }) as Record; + expect(result.useGrouping).toBeUndefined(); + expect('useGrouping' in result).toBe(false); + }); + + it('rejects a non-boolean value', () => { + expect(() => FieldSchema.parse({ type: 'number', useGrouping: 'true' })).toThrow(); + expect(() => FieldSchema.parse({ type: 'number', useGrouping: 1 })).toThrow(); + expect(() => FieldSchema.parse({ type: 'number', useGrouping: null })).toThrow(); + }); + + it('is not type-restricted at the schema level (flat on FieldSchema, like scale/min)', () => { + // FieldSchema does not discriminate its constraint keys by `type` — same + // posture as `scale`/`min`, which parse on any field type too. A type-aware + // "only meaningful on number/currency/percent" restriction is a renderer/lint + // concern, not a parse-time one. + expect(() => FieldSchema.parse({ type: 'text', useGrouping: false })).not.toThrow(); + }); + + it('does not disturb FieldSchema unknown-key strictness (#4001)', () => { + expect(() => FieldSchema.parse({ + type: 'number', + useGrouping: false, + totallyBogusKey: true, + } as unknown as Field)).toThrow(/Unrecognized key/); + }); + + it('Field.number(...) threads useGrouping through like scale/min (no special-casing needed)', () => { + const f = Field.number({ label: 'Founded Year', scale: 0, min: 1900, useGrouping: false }); + expect(f).toEqual({ type: 'number', label: 'Founded Year', scale: 0, min: 1900, useGrouping: false }); + expect(() => FieldSchema.parse(f)).not.toThrow(); + }); + + it('declares a boolean JSON-Schema slot with NO default — absence defers to the renderer', () => { + const js = z.toJSONSchema(FieldSchema as unknown as z.ZodType, { + unrepresentable: 'any', + io: 'input', + }) as any; + const prop = js.properties?.useGrouping; + 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. + expect(prop.default).toBeUndefined(); + expect(js.required ?? []).not.toContain('useGrouping'); + }); + }); + describe('Select Field', () => { it('should accept select field with options', () => { const selectField: Field = { diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 91aee8aa35..4abef963ba 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -534,6 +534,45 @@ export const FieldSchema = lazySchema(() => strictObject({ scale: z.number().optional().describe('Decimal places'), min: z.number().optional().describe('Minimum value'), max: z.number().optional().describe('Maximum value'), + /** + * 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 + * `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 + * 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 + * detail that had nothing to do with the field's TYPE. + * + * 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); + * 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 + * asserting what an unconstrained field means. + * - **`false`** — the author's explicit opt-out: this integer is an + * identifier/ordinal (year, ID, zip code, quantity meant to scan + * un-grouped), never grouped regardless of what the renderer's + * heuristic would have guessed. + * - **`true`** — the author pins grouping ON, overriding the heuristic + * the other way (a large monetary-like count that should always read + * with separators even if it would otherwise be judged ordinal-shaped). + * + * Maps 1:1 onto `Intl.NumberFormat`'s `useGrouping` option; the console + * 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. + */ + useGrouping: z.boolean().optional().describe('Digit-grouping presentation hint for `number` fields (#7768) — 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.'), /** * Media Constraints (ADR-0104 D3 wave 2) From f4664678f5c5b50ce372f6930c3c5f32a983912e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:54:33 +0000 Subject: [PATCH 2/2] chore(spec): regenerate field docs/authorable-surface/liveness after main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wholesale regen (gen:docs, gen:schema's authorable-surface projection, gen:liveness-counts) to re-materialize artifacts that drifted from commits main picked up since this branch's last merge — the `internal` field key (#7728) and the `flows` translation surface's planned entries (#7763). check:generated: 13/13 green; check:liveness: green. --- content/docs/references/data/field.mdx | 1 + packages/spec/authorable-surface/data.json | 1 + packages/spec/liveness/state-counts.md | 6 +++--- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index a05fd42181..c31ad9f95f 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -106,6 +106,7 @@ const result = CurrencyConfigSchema.parse(data); | **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional | Hidden from default UI | +| **internal** | `boolean` | optional | [#7728] Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | | **readonly** | `boolean` | optional | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE (#2948/#3003) and on INSERT (#3043; a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`, #3493) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply (#6640): a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate). | | **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning (#3420). No effect on non-password fields. | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 0e231d6c89..a8d7da72d3 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -381,6 +381,7 @@ "data/Field:inlineEdit", "data/Field:inlineHelpText", "data/Field:inlineTitle", + "data/Field:internal", "data/Field:label", "data/Field:language", "data/Field:lookupColumns", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index d8fe9de0ff..2648a9b2ee 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -28,7 +28,7 @@ for both corollaries. | Type | live | exp | dead | planned | classified | |---|---|---|---|---|---| | `object` | 49 | 0 | 0 | 1 | 50 | -| `field` | 66 | 0 | 0 | 1 | 67 | +| `field` | 67 | 0 | 0 | 1 | 68 | | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 42 | 0 | 2 | 0 | 44 | | `hook` | 18 | 0 | 2 | 0 | 20 | @@ -52,9 +52,9 @@ for both corollaries. | `job` | 15 | 0 | 0 | 0 | 15 | | `mapping` | 14 | 0 | 0 | 0 | 14 | | `seed` | 12 | 0 | 0 | 0 | 12 | -| `translation` | 19 | 0 | 0 | 0 | 19 | +| `translation` | 19 | 0 | 0 | 2 | 21 | | `validation` | 15 | 0 | 3 | 0 | 18 | | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **776** | **6** | **52** | **6** | **840** | +| **total** | **777** | **6** | **52** | **8** | **843** |