Skip to content
Merged
28 changes: 28 additions & 0 deletions .changeset/20045-inline-grid-column-currency-scale-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@objectstack/spec": minor
---

fix(spec)!: `scale` is refused on a `currency` inline grid column, and the column's `prefix` no longer promises a default symbol (#20045)

Clause-②: no (narrowing)

**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. An `inlineColumns` entry that declares `type: 'currency'` and `scale` — any value, `scale: 0` included, computed or not — no longer parses. The hand-migration prescription is registered under protocol major 18 as `inline-grid-column-currency-scale-refused`.

A currency's decimal places are the currency's, not a setting. `scale` was already retired from the `currency` field type; the inline grid column, the strict mirror of the console grid's column, still offered per-column decimals on a currency column. It now follows the field.

**`@objectstack/spec`** — `InlineGridColumnSchema` refuses `scale` on a column declaring `type: 'currency'`, with a located issue at the column's `scale`. The refusal opens with the currency field refusal's first sentence and carries its remedy: delete the key; the currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. The remedy names no other key to carry the value. No alias and no grace window. `scale` on a `number` column, and on a column that declares no `type`, is untouched, and the key's describe now names the currency refusal. The column's `prefix` describe no longer promises a `¥` default: it replaces the resolved currency's symbol, and when it is omitted the cell shows the symbol of the currency it resolves. `prefix` is still accepted on a currency column.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `inlineColumns: [{ name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price', scale: 2 }]` | `inlineColumns: [{ name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price' }]` |
| `inlineColumns: [{ name: 'unit_price', type: 'currency', prefix: '$', scale: 2 }]` | `inlineColumns: [{ name: 'unit_price', type: 'currency', prefix: '$' }]` |

The one-line fix: delete `scale` from every inline grid column that declares `type: 'currency'`. Nothing replaces it, so ⛔ do not re-declare the value under any other key. A `number` column keeps its `scale`.

## Who is affected, measured

On `origin/main` `1c8b320a89`: one authored `inlineColumns` block in the tree (the showcase invoice, seven identity-only columns, none declaring `type` or `scale`). No platform object, skill, documentation example or JSON fixture declares an inline grid column. One test fixture carried `scale: 2` on a currency column and was re-judged. Deployed metadata was not measured.

<!-- adr-0087: registered inline-grid-column-currency-scale-refused -->
8 changes: 4 additions & 4 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ const result = CurrencyConfigSchema.parse(data);
| **width** | `number` | optional | Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed). |
| **required** | `boolean` | optional | Cell is flagged inline-invalid while empty. Computed columns are never required. |
| **options** | `{ label: string; value: string }[]` | optional | Select-cell options for `type: 'select'`; derived from the child field's options when the column declares no `type`. |
| **prefix** | `string` | optional | Currency symbol rendered inside a `currency` cell (default '¥'). |
| **prefix** | `string` | optional | Symbol shown in a `currency` cell in place of the resolved currency's own symbol. No default: when omitted, the cell shows the symbol of the currency it resolves. It replaces the symbol only — the amount's decimal places stay the currency's. |
| **step** | `number` | optional | Input step for numeric cells. |
| **reference** | `string` | optional | Referenced object for `type: 'lookup'` cells; derived from the child lookup field when the column declares no `type`. |
| **displayField** | `string` | optional | Label field shown for a picked lookup record. |
Expand All @@ -216,7 +216,7 @@ const result = CurrencyConfigSchema.parse(data);
| **defaultHidden** | `boolean` | optional | Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden. |
| **computed** | `boolean` | optional | Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row. |
| **expr** | `string` | optional | Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here. |
| **scale** | `integer` | optional | Decimal places to round a computed numeric/currency result to (integer 0-100). The upper bound is the renderer's: the grid rounds with `toFixed`, which throws a RangeError above 100. |
| **scale** | `integer` | optional | Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: 'currency'` — delete it there: the currency's ISO 4217 minor unit decides. The upper bound is the renderer's: the grid rounds with `toFixed`, which throws a RangeError above 100. |
| **autofill** | `boolean` | optional | For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable. |
| **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). |
| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. |
Expand Down Expand Up @@ -356,7 +356,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name`
| **width** | `number` | optional | Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed). |
| **required** | `boolean` | optional | Cell is flagged inline-invalid while empty. Computed columns are never required. |
| **options** | `{ label: string; value: string }[]` | optional | Select-cell options for `type: 'select'`; derived from the child field's options when the column declares no `type`. |
| **prefix** | `string` | optional | Currency symbol rendered inside a `currency` cell (default '¥'). |
| **prefix** | `string` | optional | Symbol shown in a `currency` cell in place of the resolved currency's own symbol. No default: when omitted, the cell shows the symbol of the currency it resolves. It replaces the symbol only — the amount's decimal places stay the currency's. |
| **step** | `number` | optional | Input step for numeric cells. |
| **reference** | `string` | optional | Referenced object for `type: 'lookup'` cells; derived from the child lookup field when the column declares no `type`. |
| **displayField** | `string` | optional | Label field shown for a picked lookup record. |
Expand All @@ -366,7 +366,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name`
| **defaultHidden** | `boolean` | optional | Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden. |
| **computed** | `boolean` | optional | Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row. |
| **expr** | `string` | optional | Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here. |
| **scale** | `integer` | optional | Decimal places to round a computed numeric/currency result to (integer 0-100). The upper bound is the renderer's: the grid rounds with `toFixed`, which throws a RangeError above 100. |
| **scale** | `integer` | optional | Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: 'currency'` — delete it there: the currency's ISO 4217 minor unit decides. The upper bound is the renderer's: the grid rounds with `toFixed`, which throws a RangeError above 100. |
| **autofill** | `boolean` | optional | For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable. |
| **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). |
| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. |
Expand Down
21 changes: 19 additions & 2 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
"measured": {
"zod": "4.4.3",
"publishedSchemasWithDroppedRefinements": 207,
"droppedRefinementSites": 588,
"publishedSchemasWithDroppedRefinements": 208,
"droppedRefinementSites": 601,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
Expand Down Expand Up @@ -71,6 +71,7 @@
"manifest.objects.element.fieldGroups",
"manifest.objects.element.fields.out.valueType",
"manifest.objects.element.fields.out.valueType.currencyConfig",
"manifest.objects.element.fields.out.valueType.inlineColumns.element",
"manifest.objects.element.lifecycle",
"manifest.pages.element",
"manifest.pages.element.slots.header.options[0].in.type",
Expand Down Expand Up @@ -170,6 +171,7 @@
"data.options[1].manifest.objects.element.fieldGroups",
"data.options[1].manifest.objects.element.fields.out.valueType",
"data.options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
"data.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element",
"data.options[1].manifest.objects.element.lifecycle",
"data.options[1].manifest.pages.element",
"data.options[1].manifest.pages.element.slots.header.options[0].in.type",
Expand Down Expand Up @@ -254,6 +256,7 @@
"options[1].manifest.objects.element.fieldGroups",
"options[1].manifest.objects.element.fields.out.valueType",
"options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
"options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element",
"options[1].manifest.objects.element.lifecycle",
"options[1].manifest.pages.element",
"options[1].manifest.pages.element.slots.header.options[0].in.type",
Expand Down Expand Up @@ -299,6 +302,7 @@
"data.packages.element.options[1].manifest.objects.element.fieldGroups",
"data.packages.element.options[1].manifest.objects.element.fields.out.valueType",
"data.packages.element.options[1].manifest.objects.element.fields.out.valueType.currencyConfig",
"data.packages.element.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element",
"data.packages.element.options[1].manifest.objects.element.lifecycle",
"data.packages.element.options[1].manifest.pages.element",
"data.packages.element.options[1].manifest.permissions.element.objects.valueType.out",
Expand Down Expand Up @@ -338,6 +342,7 @@
"data.fieldGroups",
"data.fields.out.valueType",
"data.fields.out.valueType.currencyConfig",
"data.fields.out.valueType.inlineColumns.element",
"data.fields.out.valueType.relatedListFilter.lazy",
"data.lifecycle",
"data.listViews.valueType",
Expand Down Expand Up @@ -623,6 +628,7 @@
"sites": [
"",
"currencyConfig",
"inlineColumns.element",
"relatedListFilter.lazy"
]
},
Expand Down Expand Up @@ -658,6 +664,11 @@
"object"
]
},
"data/InlineGridColumn": {
"sites": [
""
]
},
"data/InstantValue": {
"sites": [
""
Expand Down Expand Up @@ -711,6 +722,7 @@
"fieldGroups",
"fields.out.valueType",
"fields.out.valueType.currencyConfig",
"fields.out.valueType.inlineColumns.element",
"fields.out.valueType.relatedListFilter.lazy",
"lifecycle",
"listViews.valueType",
Expand All @@ -727,6 +739,7 @@
"",
"fields.valueType",
"fields.valueType.currencyConfig",
"fields.valueType.inlineColumns.element",
"fields.valueType.relatedListFilter.lazy"
]
},
Expand Down Expand Up @@ -958,6 +971,7 @@
"sites": [
"field",
"field.currencyConfig",
"field.inlineColumns.element",
"field.relatedListFilter.lazy"
]
},
Expand All @@ -980,6 +994,7 @@
"sites": [
"operations.element.options[0].field",
"operations.element.options[0].field.currencyConfig",
"operations.element.options[0].field.inlineColumns.element",
"operations.element.options[0].field.relatedListFilter.lazy",
"operations.element.options[3].object",
"operations.element.options[3].object.actions.element.in",
Expand All @@ -1004,6 +1019,7 @@
"object.fieldGroups",
"object.fields.out.valueType",
"object.fields.out.valueType.currencyConfig",
"object.fields.out.valueType.inlineColumns.element",
"object.fields.out.valueType.relatedListFilter.lazy",
"object.lifecycle",
"object.listViews.valueType",
Expand Down Expand Up @@ -1039,6 +1055,7 @@
"sites": [
"options[0].field",
"options[0].field.currencyConfig",
"options[0].field.inlineColumns.element",
"options[0].field.relatedListFilter.lazy",
"options[3].object",
"options[3].object.actions.element.in",
Expand Down
Loading
Loading