From 8b553eedad31405c6d93655870f7d689d42bd143 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:12:24 +0000 Subject: [PATCH 1/6] fix(spec)!: refuse `scale` on a currency inline grid column; `prefix` promises no default symbol InlineGridColumnSchema now refuses an authored `scale` on a column that declares `type: 'currency'`, carrying the currency-field refusal's first sentence and remedy (delete the key; the currency's ISO 4217 minor unit decides). `scale` on a number column, and on a column that declares no type, is unchanged. The `prefix` describe no longer promises a yen default: it replaces the resolved currency's symbol and has none. ADR-0087 D3 semantic entry inline-grid-column-currency-scale-refused registered under protocol major 18; registry regenerated. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude --- packages/spec/src/data/field.zod.ts | 46 +++- ...grid-column-currency-scale-refused.test.ts | 212 ++++++++++++++++++ .../src/data/inline-related-columns.test.ts | 5 +- ...line-grid-column-currency-scale-refused.ts | 41 ++++ packages/spec/src/migrations/registry.ts | 37 +++ 5 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 packages/spec/src/data/inline-grid-column-currency-scale-refused.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 8f35ae5d394..147220988d0 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -873,6 +873,24 @@ const INLINE_GRID_COLUMN_HISTORY = 'Until this shape was closed these parsed as `z.any()` — a mis-keyed column published ' + 'clean and rendered as blank cells, with nothing naming the wrong key.'; +/** + * #20045 — the refusal of `scale` on a `currency` inline grid column: ruling + * 5791803339 (#19629, batch #215 item 1, letter B — `scale` retired from the + * currency FIELD type) carried to this mirror in its own shape, with the + * remedy ruling 5805782503 words (#19910, batch #218 item 2, letter 乙 — 「a + * currency's ISO 4217 minor unit decides its display」). Same first sentence + * as `FieldSchema`'s currency refusal with the subject swapped, same remedy: + * delete the key, nothing replaces it. + * `inline-grid-column-currency-scale-refused.test.ts` holds the two refusals + * to the same first sentence and the same minor-unit clause, so a rewording of + * one that leaves the other behind goes red. + */ +const INLINE_GRID_CURRENCY_SCALE_REFUSAL = + '`scale` is not valid on a `currency` inline grid column — delete the key. A currency ' + + 'amount\'s decimal places are its currency\'s, not a column setting: the currency\'s ISO 4217 ' + + 'minor unit (2 for USD, 0 for JPY, 3 for KWD) decides how the cell displays the amount and the ' + + 'width a computed amount is rounded to. `scale` stays valid on a `number` column.'; + /** * One explicit column of the inline master-detail grid (`inlineColumns`). * @@ -921,7 +939,11 @@ export const InlineGridColumnSchema = lazySchema(() => strictObject({ label: z.string().describe('Option label shown in the select cell.'), value: z.string().min(1).describe("Stored option value; must match the child select field's option values."), })).optional().describe("Select-cell options for `type: 'select'`; derived from the child field's options when the column declares no `type`."), - prefix: z.string().optional().describe("Currency symbol rendered inside a `currency` cell (default '¥')."), + // #20045 — no default symbol: the grid shows the resolved currency's own + // symbol when this is omitted (objectui GridField `currencyAdornment`, + // objectui#10355), so the former 「(default '¥')」 described a fallback the + // renderer no longer has. + prefix: z.string().optional().describe("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: z.number().positive().optional().describe('Input step for numeric cells.'), reference: z.string().optional().describe("Referenced object for `type: 'lookup'` cells; derived from the child lookup field when the column declares no `type`."), displayField: z.string().optional().describe('Label field shown for a picked lookup record.'), @@ -934,12 +956,30 @@ export const InlineGridColumnSchema = lazySchema(() => strictObject({ // #18972 — the upper bound is the SAME platform ceiling as `FieldSchema.scale` // below, reached by a different primitive: this key is the one objectui's // `computeRow` hands to `Number(v.toFixed(scale))`, which throws above 100. - // See {@link MAX_RENDERABLE_SCALE}. + // See {@link MAX_RENDERABLE_SCALE}. #20045 — refused on a column declaring + // `type: 'currency'` by the `.superRefine` below (ruling B carried to this + // mirror); the describe names the one type set it still applies to. scale: z.number().int().nonnegative().max(MAX_RENDERABLE_SCALE, { message: SCALE_UPPER_BOUND_MESSAGE }).optional() - .describe('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.'), + .describe('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: z.boolean().optional().describe("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: EvaluatedExpressionInputSchema.optional().describe("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: EvaluatedExpressionInputSchema.optional().describe('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.'), +}).superRefine((column, ctx) => { + // #20045 — ruling B (5791803339) on #19629 retired `scale` from the currency + // FIELD type; triage read this card as inherited from that ruling and from + // ruling 乙 (5805782503) on #19910, so the mirror follows the field: an + // authored `scale` on a column that DECLARES `type: 'currency'` is refused, + // with the remedy 「delete it; the currency's minor unit decides」. `scale` + // has no schema default, so `undefined` here always means "not authored" and + // `parse(parse(x))` stays stable. ⛔ No alias and no grace window (ruling B's + // own words). Reach, deliberately: only the DECLARED `type` is visible here. + // An identity-only column takes its type from the child field at render time + // (objectui `hydrateColumns`), which this schema cannot see. Stored metadata + // carrying the key is covered by the ADR-0087 semantic entry + // `inline-grid-column-currency-scale-refused`. + if (column.type === 'currency' && column.scale !== undefined) { + ctx.addIssue({ code: 'custom', path: ['scale'], message: INLINE_GRID_CURRENCY_SCALE_REFUSAL }); + } })); /** diff --git a/packages/spec/src/data/inline-grid-column-currency-scale-refused.test.ts b/packages/spec/src/data/inline-grid-column-currency-scale-refused.test.ts new file mode 100644 index 00000000000..6424e7799ef --- /dev/null +++ b/packages/spec/src/data/inline-grid-column-currency-scale-refused.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20045 — `scale` on an inline grid column that declares `type: 'currency'` + * is refused at parse. Ruling 5791803339 (#19629, letter B) retired `scale` + * from the currency FIELD type, and ruling 5805782503 (#19910, letter 乙 — + * 「a currency's ISO 4217 minor unit decides its display」) worded the remedy. + * Neither reached `InlineGridColumnSchema`, the strict mirror of the console + * grid's column, which still offered per-column decimals on a currency column + * and described a `¥` default symbol the grid no longer has. Triage read the + * card as inherited from both rulings, so the mirror follows the field. + * + * What is pinned here (triage execution note 5): + * + * 1. THE REFUSAL — through the `FieldSchema` door on a real relationship + * field (strictness and refinements do not recurse by themselves, so a + * standalone parse proves nothing about the carrier), located at the + * column's `scale`, carrying ruling B's first sentence and remedy. Every + * declared value is refused, computed or not. + * 2. THE CONTROLS — `scale` on a `number` column, and on a column that + * declares no `type`, parses with its value; `prefix` on a currency + * column is still accepted; following the remedy parses. + * 3. THE SHAPE PARITY — the field refusal and the column refusal share the + * first sentence (subject swapped) and the minor-unit clause, so a + * rewording of one that leaves the other behind goes red. + * 4. THE DESCRIBES — `prefix` no longer promises a default symbol, and + * `scale` names the currency refusal. + * 5. THE ADR-0087 D3 ENTRY — registered under protocol major 18. + * + * Key-vs-value note: the rule judges the KEY on one declared type, whatever + * its value, so the refusal is a full `safeParse` failure located at `scale` + * and every control is a full `safeParse` success — never mere absence of + * `unrecognized_keys`. + */ + +import { describe, expect, it } from 'vitest'; +import { FieldSchema, InlineGridColumnSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Result = { success: boolean; error?: { issues: Issue[] }; data?: unknown }; + +/** The issues whose path ends at a `scale` key, or [] when the parse passed. */ +function scaleIssues(result: Result): Issue[] { + return result.success ? [] : result.error!.issues.filter((i) => i.path[i.path.length - 1] === 'scale'); +} + +/** A minimal master_detail relationship field — the real carrier of `inlineColumns`. */ +const MD_FIELD = { + name: 'invoice', + label: 'Invoice', + type: 'master_detail', + reference: 'showcase_invoice', + inlineEdit: 'grid', +} as const; + +const parseColumns = (inlineColumns: Record[]): Result => + FieldSchema.safeParse({ ...MD_FIELD, inlineColumns }) as Result; + +const FIELD_FIRST_SENTENCE = '`scale` is not valid on a `currency` field — delete the key.'; +const COLUMN_FIRST_SENTENCE = '`scale` is not valid on a `currency` inline grid column — delete the key.'; +const MINOR_UNIT_CLAUSE = 'the currency\'s ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) decides how'; + +/** + * The ruled remedy on the message itself: the wording is the contract here + * (the card carries ruling B's sentence and remedy to the mirror), so the pin + * reads the first sentence verbatim, the minor-unit clause, and the ABSENCE of + * any key the author could be sent to instead. + */ +function expectRuledRemedy(message: string): void { + expect(message.startsWith(COLUMN_FIRST_SENTENCE)).toBe(true); + expect(message).toContain(MINOR_UNIT_CLAUSE); + expect(message).not.toMatch(/currencyConfig|precision/); +} + +describe('#20045 — `scale` on a currency inline grid column is refused at parse', () => { + it('refuses the card\'s repro (computed currency column, `scale: 4`), located at the column\'s `scale`, with the ruled remedy', () => { + const result = parseColumns([ + { name: 'quantity' }, + { name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price', scale: 4 }, + ]); + expect(result.success).toBe(false); + const issues = scaleIssues(result); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('custom'); + expect(issues[0].path).toEqual(['inlineColumns', 1, 'scale']); + expectRuledRemedy(issues[0].message); + }); + + it('refuses every declared value on a currency column, computed or not — it is the key that is retired there', () => { + for (const scale of [0, 2, 10]) { + for (const computed of [{}, { computed: true, expr: 'quantity * unit_price' }]) { + const label = `scale: ${scale}, ${JSON.stringify(computed)}`; + const result = parseColumns([{ name: 'amount', type: 'currency', ...computed, scale }]); + expect(result.success, label).toBe(false); + const issues = scaleIssues(result); + expect(issues, label).toHaveLength(1); + expect(issues[0].path, label).toEqual(['inlineColumns', 0, 'scale']); + } + } + }); + + it('fires through `ObjectSchema` — the path an object document crosses', () => { + const result = ObjectSchema.safeParse({ + name: 'showcase_invoice_line', + label: 'Invoice Line', + fields: { + invoice: { ...MD_FIELD, inlineColumns: [{ name: 'unit_price', type: 'currency', prefix: '$', scale: 2 }] }, + }, + }) as Result; + expect(result.success).toBe(false); + const issues = scaleIssues(result); + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['fields', 'invoice', 'inlineColumns', 0, 'scale']); + expectRuledRemedy(issues[0].message); + }); + + it('is the column schema\'s own refusal — a standalone column parse answers the same issue at `scale`', () => { + const result = InlineGridColumnSchema.safeParse({ name: 'amount', type: 'currency', scale: 2 }) as Result; + expect(result.success).toBe(false); + const issues = scaleIssues(result); + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['scale']); + expectRuledRemedy(issues[0].message); + }); +}); + +describe('#20045 — CONTROLS: what the refusal must leave alone', () => { + it('`scale` on a `number` column parses, computed or not, and keeps its value', () => { + for (const computed of [{}, { computed: true, expr: 'quantity * unit_price' }]) { + const result = parseColumns([{ name: 'weight', type: 'number', ...computed, scale: 3 }]); + expect(result.success, JSON.stringify(computed)).toBe(true); + const columns = (result.data as { inlineColumns: Array> }).inlineColumns; + expect(columns[0].scale).toBe(3); + } + }); + + it('`scale` on a column that declares no `type` parses — the refusal judges only a DECLARED type', () => { + const result = parseColumns([{ name: 'amount', computed: true, expr: 'quantity * unit_price', scale: 2 }]); + expect(result.success).toBe(true); + const columns = (result.data as { inlineColumns: Array> }).inlineColumns; + expect(columns[0].scale).toBe(2); + }); + + it('`prefix` is still accepted on a currency column, and its value round-trips', () => { + const result = parseColumns([{ name: 'unit_price', type: 'currency', prefix: 'US$' }]); + expect(result.success).toBe(true); + const columns = (result.data as { inlineColumns: Array> }).inlineColumns; + expect(columns[0]).toMatchObject({ name: 'unit_price', type: 'currency', prefix: 'US$' }); + }); + + it('following the remedy parses: each refused column, with `scale` deleted and nothing added, is accepted and re-parses unchanged', () => { + const refused: Record[] = [ + { name: 'amount', type: 'currency', scale: 0 }, + { name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price', prefix: '$', scale: 4 }, + ]; + for (const column of refused) { + expect(parseColumns([column]).success, JSON.stringify(column)).toBe(false); + const remedied = { ...column }; + delete remedied.scale; + expect(Object.keys(remedied)).toEqual(Object.keys(column).filter((k) => k !== 'scale')); + const result = parseColumns([remedied]); + expect(result.success, JSON.stringify(remedied)).toBe(true); + const once = result.data as { inlineColumns: Array> }; + expect('scale' in once.inlineColumns[0]).toBe(false); + expect(FieldSchema.parse(once)).toEqual(once); + } + }); +}); + +describe('#20045 — SHAPE PARITY with the currency FIELD refusal (ruling B carried, not reworded)', () => { + it('both refusals open with the same sentence, subject swapped, and share the minor-unit clause', () => { + const field = FieldSchema.safeParse({ name: 'amount', label: 'Amount', type: 'currency', scale: 2 }) as Result; + const column = parseColumns([{ name: 'amount', type: 'currency', scale: 2 }]); + const [fieldIssue] = scaleIssues(field); + const [columnIssue] = scaleIssues(column); + expect(fieldIssue.message.startsWith(FIELD_FIRST_SENTENCE)).toBe(true); + expect(COLUMN_FIRST_SENTENCE).toBe(FIELD_FIRST_SENTENCE.replace('`currency` field', '`currency` inline grid column')); + expect(columnIssue.message.startsWith(COLUMN_FIRST_SENTENCE)).toBe(true); + for (const message of [fieldIssue.message, columnIssue.message]) { + expect(message).toContain(MINOR_UNIT_CLAUSE); + expect(message).not.toMatch(/currencyConfig|precision/); + } + }); +}); + +describe('#20045 — the column describes', () => { + const shape = InlineGridColumnSchema.shape as Record; + + it('`prefix` replaces the resolved currency symbol and promises no default', () => { + const description = shape.prefix?.description ?? ''; + expect(description).toContain('in place of the resolved currency\'s own symbol'); + expect(description).toContain('No default'); + expect(description).not.toContain('¥'); + }); + + it('`scale` names the currency refusal and no longer offers a currency result', () => { + const description = shape.scale?.description ?? ''; + expect(description).toContain('REFUSED on a column declaring `type: \'currency\'` — delete it there'); + expect(description).not.toContain('numeric/currency'); + }); +}); + +describe('#20045 — the ADR-0087 D3 entry', () => { + it('is registered under protocol major 18 with a replacement that deletes the key and names no carrier', () => { + const entry = MIGRATIONS_BY_MAJOR[18]?.semantic.find((e) => e.id === 'inline-grid-column-currency-scale-refused'); + expect(entry).toBeDefined(); + expect(entry!.replacement).toContain('DELETE the key'); + expect(entry!.replacement).not.toMatch(/currencyConfig|precision/); + }); +}); diff --git a/packages/spec/src/data/inline-related-columns.test.ts b/packages/spec/src/data/inline-related-columns.test.ts index b437ca5d890..1635849e2e4 100644 --- a/packages/spec/src/data/inline-related-columns.test.ts +++ b/packages/spec/src/data/inline-related-columns.test.ts @@ -109,7 +109,10 @@ describe('#9227 inlineColumns — strict name-keyed element', () => { prefix: '$', step: 0.01, defaultHidden: false, - scale: 2, + // No `scale` here since #20045: it is refused on a column declaring + // `type: 'currency'` (pinned in + // `inline-grid-column-currency-scale-refused.test.ts`). The key stays + // covered by the identity-only computed `amount` column below. }, { name: 'product', type: 'lookup', diff --git a/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts new file mode 100644 index 00000000000..3df12281d19 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'inline-grid-column-currency-scale-refused', + surface: 'object.fields..inlineColumns[].scale on an inline grid column that declares ' + + '`type: \'currency\'` — any declared value, `scale: 0` included, computed or not. `scale` on a ' + + '`number` column, and on a column that declares no `type`, is untouched', + replacement: 'no `scale` on a currency inline grid column. DELETE the key — that is the whole ' + + 'migration: a currency amount\'s decimal places are its currency\'s, not a column setting. The ' + + 'currency\'s ISO 4217 minor unit decides how the cell displays the amount and the width a ' + + 'computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under ' + + 'any other key.', + reason: + 'Maintainer ruling 5791803339 (batch #215 item 1, letter B) retired `scale` from the ' + + '`currency` field type, and ruling 5805782503 (batch #218 item 2, letter 乙 — a currency\'s ' + + 'ISO 4217 minor unit decides its display) worded the remedy. Neither reached the inline grid ' + + 'column, the strict mirror of the console grid\'s column, which still offered per-column ' + + 'decimals on a `currency` column; triage read the column as inherited from both rulings, so ' + + '`InlineGridColumnSchema` now refuses the key on a column declaring `type: \'currency\'` at ' + + 'parse, with the field refusal\'s first sentence and remedy. ⛔ No alias and no grace window, ' + + 'per ruling B. NOT mechanically converted, deliberately, for the reason the field entry ' + + '`field-currency-scale-refused` gives: a conversion that dropped the key would accept it on ' + + 'every load, which is the grace window the ruling refused; the refusal names the key and its ' + + 'one-line fix instead. The same change rewords the column\'s `prefix` description: it replaces ' + + 'the resolved currency\'s symbol and has no default (the grid no longer falls back to a fixed ' + + 'yen sign). Reach: only a DECLARED column `type` is judged — a column that declares none takes ' + + 'its type from the child field when the console hydrates it, which the column schema cannot ' + + 'see. Population measured at the change, 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 ' + + 'declaring an inline grid column at all, and one test fixture carrying `scale: 2` on a currency ' + + 'column, re-judged in the same change. Deployed metadata NOT MEASURED.', + acceptanceCriteria: + 'Every field in the stack parses: an `ObjectSchema` parse and `objectstack validate` report no ' + + 'issue on an `inlineColumns[].scale` path of a column declaring `type: \'currency\'`. A ' + + 'currency column that carried `scale` no longer declares it, and a diff of the column shows ' + + 'that one line deleted and no key added. `number` columns, and columns declaring no `type`, ' + + 'keep their `scale`; a column\'s `prefix` is still accepted on a currency column.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9a266436f8a..4761912d031 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9972,6 +9972,43 @@ const step18: MigrationStep = { + 'and must be verified as such: nothing ever parsed or read these shapes, so removing ' + 'them removes no behaviour.', }, + { + id: 'inline-grid-column-currency-scale-refused', + surface: 'object.fields..inlineColumns[].scale on an inline grid column that declares ' + + '`type: \'currency\'` — any declared value, `scale: 0` included, computed or not. `scale` on a ' + + '`number` column, and on a column that declares no `type`, is untouched', + replacement: 'no `scale` on a currency inline grid column. DELETE the key — that is the whole ' + + 'migration: a currency amount\'s decimal places are its currency\'s, not a column setting. The ' + + 'currency\'s ISO 4217 minor unit decides how the cell displays the amount and the width a ' + + 'computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under ' + + 'any other key.', + reason: + 'Maintainer ruling 5791803339 (batch #215 item 1, letter B) retired `scale` from the ' + + '`currency` field type, and ruling 5805782503 (batch #218 item 2, letter 乙 — a currency\'s ' + + 'ISO 4217 minor unit decides its display) worded the remedy. Neither reached the inline grid ' + + 'column, the strict mirror of the console grid\'s column, which still offered per-column ' + + 'decimals on a `currency` column; triage read the column as inherited from both rulings, so ' + + '`InlineGridColumnSchema` now refuses the key on a column declaring `type: \'currency\'` at ' + + 'parse, with the field refusal\'s first sentence and remedy. ⛔ No alias and no grace window, ' + + 'per ruling B. NOT mechanically converted, deliberately, for the reason the field entry ' + + '`field-currency-scale-refused` gives: a conversion that dropped the key would accept it on ' + + 'every load, which is the grace window the ruling refused; the refusal names the key and its ' + + 'one-line fix instead. The same change rewords the column\'s `prefix` description: it replaces ' + + 'the resolved currency\'s symbol and has no default (the grid no longer falls back to a fixed ' + + 'yen sign). Reach: only a DECLARED column `type` is judged — a column that declares none takes ' + + 'its type from the child field when the console hydrates it, which the column schema cannot ' + + 'see. Population measured at the change, 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 ' + + 'declaring an inline grid column at all, and one test fixture carrying `scale: 2` on a currency ' + + 'column, re-judged in the same change. Deployed metadata NOT MEASURED.', + acceptanceCriteria: + 'Every field in the stack parses: an `ObjectSchema` parse and `objectstack validate` report no ' + + 'issue on an `inlineColumns[].scale` path of a column declaring `type: \'currency\'`. A ' + + 'currency column that carried `scale` no longer declares it, and a diff of the column shows ' + + 'that one line deleted and no key added. `number` columns, and columns declaring no `type`, ' + + 'keep their `scale`; a column\'s `prefix` is still accepted on a currency column.', + }, { id: 'kernel-compatibility-matrix-estimated-migration-time-unit-in-key', surface: 'CompatibilityMatrixEntry.estimatedMigrationTime, the migration effort estimate whose ' From 015ff0fe864993afd26b4e1bf3a101a40206b485 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:14:35 +0000 Subject: [PATCH 2/6] chore(spec): declare the inline grid column's currency-scale refinement in the dropped-refinements ledger The new InlineGridColumnSchema refinement has no JSON Schema form, so the build's dropped-refinement ratchet names it: one new row for data/InlineGridColumn (site at the root) and one `inlineColumns.element` site on each of the twelve published schemas that embed the column, exactly as the gate printed them; header totals moved with the body. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude --- .../spec/dropped-refinements.baseline.json | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 77a87e632cc..fddb921e87f 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -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": 574, + "publishedSchemasWithDroppedRefinements": 208, + "droppedRefinementSites": 587, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -69,6 +69,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", @@ -166,6 +167,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", @@ -248,6 +250,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", @@ -291,6 +294,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", @@ -330,6 +334,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", @@ -614,6 +619,7 @@ "sites": [ "", "currencyConfig", + "inlineColumns.element", "relatedListFilter.lazy" ] }, @@ -648,6 +654,11 @@ "object" ] }, + "data/InlineGridColumn": { + "sites": [ + "" + ] + }, "data/InstantValue": { "sites": [ "" @@ -700,6 +711,7 @@ "fieldGroups", "fields.out.valueType", "fields.out.valueType.currencyConfig", + "fields.out.valueType.inlineColumns.element", "fields.out.valueType.relatedListFilter.lazy", "lifecycle", "listViews.valueType", @@ -716,6 +728,7 @@ "", "fields.valueType", "fields.valueType.currencyConfig", + "fields.valueType.inlineColumns.element", "fields.valueType.relatedListFilter.lazy" ] }, @@ -947,6 +960,7 @@ "sites": [ "field", "field.currencyConfig", + "field.inlineColumns.element", "field.relatedListFilter.lazy" ] }, @@ -969,6 +983,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", @@ -993,6 +1008,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", @@ -1028,6 +1044,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", From 4dbb609b1a696e37de7b3297449f328b2b21f2fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:26:35 +0000 Subject: [PATCH 3/6] docs(spec): regenerate the field reference page for the inline column's prefix and scale describes Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude --- content/docs/references/data/field.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index f186a3de4d7..5e808edb5b5 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -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. | @@ -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. | @@ -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. | @@ -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. | From aa4b6ca14f24b7c36a117dd88477bf14c82bff1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:47:52 +0000 Subject: [PATCH 4/6] chore(changeset): spec minor with BREAKING banner for the currency inline column scale refusal Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude --- ...line-grid-column-currency-scale-refused.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/20045-inline-grid-column-currency-scale-refused.md diff --git a/.changeset/20045-inline-grid-column-currency-scale-refused.md b/.changeset/20045-inline-grid-column-currency-scale-refused.md new file mode 100644 index 00000000000..f6fd583de3a --- /dev/null +++ b/.changeset/20045-inline-grid-column-currency-scale-refused.md @@ -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-②: yes (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. + + From df6b175e589c20cad7f54d25bdbdedc5b0a566b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:14:18 +0000 Subject: [PATCH 5/6] docs(spec): regenerate the field reference page on the merged tree The os-regen merge driver kept the branch's bytes of field.mdx and dropped main's CurrencyConfig.defaultCurrency describe rows; regenerated from the merged sources (gen:schema then gen:docs), which restores main's rows and keeps this branch's inline-column prefix/scale rows. registry.ts was regenerated with gen:migration-registry from the merged entries/ directory and came out byte-identical to the merge. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude --- content/docs/references/data/field.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 5e808edb5b5..2dd4380cbd3 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -29,7 +29,7 @@ const result = CurrencyConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **precision** | `integer` | optional (default: `2`) | Decimal precision (default: 2) | | **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. | -| **defaultCurrency** | `string` | optional (default: `"CNY"`) | Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR) | +| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | --- @@ -237,7 +237,7 @@ const result = CurrencyConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **precision** | `integer` | optional (default: `2`) | Decimal precision (default: 2) | | **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. | -| **defaultCurrency** | `string` | optional (default: `"CNY"`) | Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR) | +| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | ### Nested Shape: `Field.maskingRule` From 4ab1d0cac243ff74c4ec7951e3471c718974fb55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:14:34 +0000 Subject: [PATCH 6/6] =?UTF-8?q?chore(changeset):=20Clause-=E2=91=A1=20is?= =?UTF-8?q?=20`no=20(narrowing)`=20for=20the=20currency=20inline=20column?= =?UTF-8?q?=20scale=20refusal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the seat's ruling 5855671317 on #20045: this diff widens nothing and only refuses a shape that parsed. The narrowing arm, the level (minor) and the ADR-0087 disposition are unchanged. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude --- .changeset/20045-inline-grid-column-currency-scale-refused.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/20045-inline-grid-column-currency-scale-refused.md b/.changeset/20045-inline-grid-column-currency-scale-refused.md index f6fd583de3a..528891304cf 100644 --- a/.changeset/20045-inline-grid-column-currency-scale-refused.md +++ b/.changeset/20045-inline-grid-column-currency-scale-refused.md @@ -4,7 +4,7 @@ fix(spec)!: `scale` is refused on a `currency` inline grid column, and the column's `prefix` no longer promises a default symbol (#20045) -Clause-②: yes (narrowing) +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`.