From 14a01d486ceab18f501331354381bcfc61d142fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:24:51 +0000 Subject: [PATCH 1/5] fix(spec)!: a fixed currency config needs its code; defaultCurrency loses its CNY default currencyConfig.defaultCurrency was .default('CNY'): a fixed config with no code became a yuan field, and every dynamic config's parse output carried a CNY no reader takes. The default goes; a superRefine refuses fixed without a code with the prescription (give a code, or use dynamic), on a currency field and on a formula returning currency alike. ADR-0087: the D2 conversion currency-config-dynamic-default-currency-removed strips the baked code from dynamic stored rows and keeps fixed ones; the D3 entry currency-config-fixed-default-currency-required carries the judgment. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh --- ...157-currency-config-fixed-code-required.md | 24 ++++ content/docs/data-modeling/field-types.mdx | 2 +- .../docs/data-modeling/validation-rules.mdx | 2 +- packages/formula/src/formula-return.test.ts | 4 + packages/formula/src/formula-return.ts | 11 +- .../en.metadata-forms.generated.ts | 2 +- .../es-ES.metadata-forms.generated.ts | 2 +- .../ja-JP.metadata-forms.generated.ts | 2 +- .../zh-CN.metadata-forms.generated.ts | 2 +- packages/spec/liveness/field.json | 2 +- ...g-dynamic-default-currency-removed.test.ts | 107 ++++++++++++++++ packages/spec/src/conversions/registry.ts | 116 +++++++++++++++++- ...urrency-config-fixed-code-required.test.ts | 82 +++++++++++++ .../data/currency-precision-iso4217.test.ts | 7 +- packages/spec/src/data/field.form.ts | 7 +- packages/spec/src/data/field.test.ts | 13 +- packages/spec/src/data/field.zod.ts | 48 ++++++-- .../src/data/formula-currency-result.test.ts | 4 +- ...-config-fixed-default-currency-required.ts | 43 +++++++ .../spec/src/migrations/registry.ts.template | 12 ++ .../__tests__/resolveFieldCurrency.test.ts | 2 +- ui/packages/i18n/src/currency.ts | 12 +- 22 files changed, 467 insertions(+), 39 deletions(-) create mode 100644 .changeset/27157-currency-config-fixed-code-required.md create mode 100644 packages/spec/src/conversions/currency-config-dynamic-default-currency-removed.test.ts create mode 100644 packages/spec/src/data/currency-config-fixed-code-required.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.currency-config-fixed-default-currency-required.ts diff --git a/.changeset/27157-currency-config-fixed-code-required.md b/.changeset/27157-currency-config-fixed-code-required.md new file mode 100644 index 00000000000..3b5813fac81 --- /dev/null +++ b/.changeset/27157-currency-config-fixed-code-required.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@objectstack/formula': minor +'@objectstack/platform-objects': minor +--- + +fix(spec)!: a `fixed` currency config needs its code — `currencyConfig.defaultCurrency` loses its `'CNY'` default, and a `dynamic` config parses with no code + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** for one shape: a `currencyConfig` with `currencyMode: 'fixed'` and no `defaultCurrency`, on a currency field or on a formula field whose `returnType` is `currency`. Ruling X on #27119: an amount whose currency is not known takes the tenant default, and nothing guesses. + +- **Before.** `defaultCurrency` was `.default('CNY')`. `{ currencyMode: 'fixed' }` parsed to `{ currencyMode: 'fixed', defaultCurrency: 'CNY' }`, so the field silently became a yuan field and showed `¥` in a dollar or euro app. Every `dynamic` config's parse output carried that `'CNY'` too, though nothing reads a `dynamic` config's code. +- **Now.** A `fixed` config without a code is refused at every door that parses a field (`defineStack`, `os validate`, the object save door), with one `custom` issue at `currencyConfig.defaultCurrency`: "`currencyConfig.currencyMode: 'fixed'` needs `defaultCurrency`, the field's one currency: give its ISO 4217 code (e.g. `defaultCurrency: 'USD'`), or use `currencyMode: 'dynamic'` to display amounts in the tenant default currency (the `localization.currency` setting)." A `dynamic` config (and an empty one) parses to `{ currencyMode: 'dynamic' }`: no code is materialized. An authored code under `dynamic` is still accepted and still unread. +- **FROM → TO.** `currencyConfig: { currencyMode: 'fixed' }` → `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }` (the code the field is in; until now it displayed CNY), or `currencyMode: 'dynamic'` / no `currencyConfig` for the tenant default currency. +- **The one-line fix.** Write the code next to `currencyMode: 'fixed'`. +- **Stored rows and built artifacts (ADR-0087).** Every earlier parse baked the code in, so no stored `fixed` row lacks one: it keeps its code, which is the currency it displayed. The D2 conversion `currency-config-dynamic-default-currency-removed` strips the unread code from `dynamic` configs (and configs with no mode) on stored-row rehydration and inside a built artifact's declared-floor window, on objects and object extensions; no display changes. It is retired from the load path, so an authored source is never rewritten. The upgrade guide entry is `currency-config-fixed-default-currency-required`. +- **Readers.** None changes what it displays. The console's `resolveFieldCurrency` and the analytics field-currency relay already read the code only under `fixed`. `@objectstack/formula`'s return-type inference reads a field's currency through `CurrencyConfigSchema`; a `fixed` config without a code no longer parses there, so it reads as an unproven currency instead of CNY. +- **`@objectstack/platform-objects`.** The field form's `defaultCurrency` help text says the code is required in fixed mode instead of "Defaults to CNY", in en, zh-CN, ja-JP and es-ES. + +Survey at the change: 17 `fixed` currency configs across `examples/**`, `packages/platform-objects/**`, `packages/qa/**`, seeds and fixtures, none without a code. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 31c9d697f18..e9f194fcb94 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -153,7 +153,7 @@ code lives in `currencyConfig`, never in the value. | `min` | `number` | — | Minimum value | | `max` | `number` | — | Maximum value | | `currencyConfig.currencyMode` | `'dynamic' \| 'fixed'` | `'dynamic'` | Whether the column is pinned to `defaultCurrency` (`fixed`) or displays in the tenant default currency, the `localization.currency` setting (`dynamic`); the value is a bare number either way | -| `currencyConfig.defaultCurrency` | `string` | `'CNY'` | The column's currency code under `fixed`; not the displayed currency under `dynamic` | +| `currencyConfig.defaultCurrency` | `string` | — | The column's currency code under `fixed`, where it is required; not the displayed currency under `dynamic` | ```typescript { name: 'price', label: 'Price', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } } diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index 4060fb960fd..5a1db3dd941 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -153,7 +153,7 @@ These properties apply to **all** field types and are validated by the base `Fie | `min` | `number` | — | Minimum monetary value | | `max` | `number` | — | Maximum monetary value | | `currencyConfig.currencyMode` | `enum` | `dynamic` | `fixed` pins the field to `defaultCurrency`; `dynamic` displays the tenant default currency (the `localization.currency` setting). Not a per-record choice | -| `currencyConfig.defaultCurrency` | `string` | `CNY` | 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`; not the displayed currency under `dynamic` | +| `currencyConfig.defaultCurrency` | `string` | — | 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`, where it is required (a `fixed` config without one is refused); not the displayed currency under `dynamic` | **Default constraints:** Stored as a **bare number** (a finite numeric scalar — `valueSchemaFor` routes `currency` to `z.number().finite()`); there is no `{ value, currency }` envelope on the value path. The currency **code** is never stored per record: it is field configuration, carried by `currencyConfig` above. Decimal places are the currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) and are declared nowhere — `currencyConfig` has no decimal-places key (`currencyConfig.precision` is refused, with a prescription to delete it), and the field-level `precision` is the total digit count, never compared with the currency. diff --git a/packages/formula/src/formula-return.test.ts b/packages/formula/src/formula-return.test.ts index c7d7f3e7068..71563857550 100644 --- a/packages/formula/src/formula-return.test.ts +++ b/packages/formula/src/formula-return.test.ts @@ -38,6 +38,8 @@ const FIELDS: Readonly> = { weighted: { type: 'formula', returnType: 'currency', currencyConfig: USD }, count_formula: { type: 'formula', returnType: 'number' }, broken: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'US' } }, + // `fixed` with no code: the spec refuses it rather than filling in CNY (#27157). + fixed_no_code: { type: 'currency', currencyConfig: { currencyMode: 'fixed' } }, }; const NAMES = Object.keys(FIELDS); @@ -127,6 +129,8 @@ describe('CONTROL — a numeric formula stays `number`', () => { 'record.flag ? record.amount : record.fee_eur', // A source whose currency does not parse names no currency. 'record.broken * 2', + // Nor does a fixed source with no code: it is never read as CNY (#27157). + 'record.fixed_no_code * 2', // A roll-up's unit is its child field's, not readable here. 'record.rollup * 2', 'record.amount % 3', diff --git a/packages/formula/src/formula-return.ts b/packages/formula/src/formula-return.ts index 49abfdec9a6..ebb5349f920 100644 --- a/packages/formula/src/formula-return.ts +++ b/packages/formula/src/formula-return.ts @@ -129,13 +129,16 @@ function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean { return a.code === b.code; } -/** A field's currency, read through the schema a currency field is parsed with — or `null` when it does not parse. */ +/** + * A field's currency, read through the schema a currency field is parsed with — or `null` when it does not parse. + * A `fixed` config with no code does not parse (the schema refuses it rather than filling in a code), so the + * `defaultCurrency` guard below only narrows the type the refinement already guarantees. + */ function currencyOf(config: unknown): MoneyCurrency | null { const parsed = CurrencyConfigSchema.safeParse(config ?? {}); if (!parsed.success) return null; - return parsed.data.currencyMode === 'fixed' - ? { mode: 'fixed', code: parsed.data.defaultCurrency } - : { mode: 'dynamic' }; + if (parsed.data.currencyMode !== 'fixed') return { mode: 'dynamic' }; + return parsed.data.defaultCurrency === undefined ? null : { mode: 'fixed', code: parsed.data.defaultCurrency }; } /** Is `d` a number that is not an amount (a quantity or a constant)? */ diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 2c4187f2884..cae2d15ff62 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -618,7 +618,7 @@ export const enMetadataForms: NonNullable = { }, "currencyConfig.defaultCurrency": { label: "Default Currency", - helpText: "The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Defaults to CNY. Not read in dynamic mode." + helpText: "The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Required in fixed mode; nothing fills one in. Not read in dynamic mode." }, step: { label: "Step", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 0a1eeb3ceb7..d0e253c24c3 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -618,7 +618,7 @@ export const esESMetadataForms: NonNullable = }, "currencyConfig.defaultCurrency": { label: "Moneda predeterminada", - helpText: "La única moneda de un campo en modo fixed, como código ISO 4217 de tres caracteres (p. ej., USD, EUR). Por defecto, CNY. No se lee en modo dynamic." + helpText: "La única moneda de un campo en modo fixed, como código ISO 4217 de tres caracteres (p. ej., USD, EUR). Obligatorio en modo fixed; no se rellena automáticamente. No se lee en modo dynamic." }, step: { label: "Incremento", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index e7fe0665702..af87d92e91a 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -618,7 +618,7 @@ export const jaJPMetadataForms: NonNullable = }, "currencyConfig.defaultCurrency": { label: "既定の通貨", - helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。既定は CNY です。dynamic モードでは参照されません。" + helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。fixed モードでは必須で、自動では補われません。dynamic モードでは参照されません。" }, step: { label: "ステップ値", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index cc7e5b31c6c..289f4c2a33b 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -618,7 +618,7 @@ export const zhCNMetadataForms: NonNullable = }, "currencyConfig.defaultCurrency": { label: "默认货币", - helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。默认为 CNY。dynamic 模式下不读取。" + helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。fixed 模式下必填,不会自动填入。dynamic 模式下不读取。" }, step: { label: "步进值", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 3423ab7e685..00541ab00d5 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -391,7 +391,7 @@ "verifiedAt": "2026-09-27", "evidenceScope": "in-repo", "evidence": "ui/packages/i18n/src/currency.ts:60 reads field.currencyConfig.defaultCurrency ONLY when currencyConfig.currencyMode is 'fixed' — the second step of resolveFieldCurrency's precedence chain; a 'dynamic' config, or one naming no mode, skips it for the tenant default localization.currency; ui/packages/fields/src/widgets/CurrencyField.tsx:55 calls resolveFieldCurrency and applies the resolved code at :91 through formatAmount, which passes it to Intl.NumberFormat via formatDisplayNumber — measured objectui @25c7d584", - "note": "RE-CITED 2026-09-27 (#20126): since objectui#10461 the reader takes defaultCurrency only under currencyMode 'fixed', and the chain no longer cites the date/datetime-semantics ADR, which never names currency; line pointers moved with it (currency.ts :32 → :60, CurrencyField.tsx :45/:51 → :55/:91). Verdict unchanged: LIVE. RE-CITED 2026-08-10 (#7133/#7142): the old pointer 'packages/fields/src/index.tsx:469' now lands in formatRelativeDays, a date-phrase helper with no currency involvement — the resolver moved to @object-ui/i18n and packages/fields/src/currency.ts:14 only re-exports it, which is why the old line drifted onto unrelated code rather than disappearing. Verdict unchanged: LIVE, and now cited at both the reader that consumes the key and the widget that applies the resolved currency. The removed `currencyConfig.precision` (#19992; packages/spec/src/data/field.zod.ts; moved out of spec source, #23434): the widget's one width is the currency's ISO 4217 minor unit, objectui @b9814b334 packages/fields/src/widgets/CurrencyField.tsx#CurrencyField through objectui @b9814b334 packages/fields/src/widgets/number-format.ts#currencyDisplayWidth over objectui @b9814b334 packages/fields/src/currency.ts#currencyFractionDigits, and no objectui code reads `currencyConfig.precision`; re-read 2026-10-15, first cited sha-less (the source then named `currencyFractionDigits(currency)` as the widget's own line)." + "note": "NARROWED 2026-10-28 (#27157, ruling 6464202770 on #27119, Q2 = X): `defaultCurrency` lost its `'CNY'` default and is required under `fixed` (a `fixed` config without a code is refused with the prescription); a `dynamic` config parses with no `defaultCurrency`, and stored rows lose the baked code through the ADR-0087 conversion `currency-config-dynamic-default-currency-removed`. The reader is unchanged and was re-read on this date: ui/packages/i18n/src/currency.ts#resolveFieldCurrency still takes the code only under `fixed`, so a `dynamic` config that no longer carries `CNY` displays exactly as before. Verdict unchanged: LIVE. RE-CITED 2026-09-27 (#20126): since objectui#10461 the reader takes defaultCurrency only under currencyMode 'fixed', and the chain no longer cites the date/datetime-semantics ADR, which never names currency; line pointers moved with it (currency.ts :32 → :60, CurrencyField.tsx :45/:51 → :55/:91). Verdict unchanged: LIVE. RE-CITED 2026-08-10 (#7133/#7142): the old pointer 'packages/fields/src/index.tsx:469' now lands in formatRelativeDays, a date-phrase helper with no currency involvement — the resolver moved to @object-ui/i18n and packages/fields/src/currency.ts:14 only re-exports it, which is why the old line drifted onto unrelated code rather than disappearing. Verdict unchanged: LIVE, and now cited at both the reader that consumes the key and the widget that applies the resolved currency. The removed `currencyConfig.precision` (#19992; packages/spec/src/data/field.zod.ts; moved out of spec source, #23434): the widget's one width is the currency's ISO 4217 minor unit, objectui @b9814b334 packages/fields/src/widgets/CurrencyField.tsx#CurrencyField through objectui @b9814b334 packages/fields/src/widgets/number-format.ts#currencyDisplayWidth over objectui @b9814b334 packages/fields/src/currency.ts#currencyFractionDigits, and no objectui code reads `currencyConfig.precision`; re-read 2026-10-15, first cited sha-less (the source then named `currencyFractionDigits(currency)` as the widget's own line)." }, "dimensions": { "status": "live", diff --git a/packages/spec/src/conversions/currency-config-dynamic-default-currency-removed.test.ts b/packages/spec/src/conversions/currency-config-dynamic-default-currency-removed.test.ts new file mode 100644 index 00000000000..ac3074034e7 --- /dev/null +++ b/packages/spec/src/conversions/currency-config-dynamic-default-currency-removed.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { ObjectSchema } from '../data/object.zod.js'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry.js'; +import { applyConversions } from './apply.js'; +import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; +import type { ConversionNotice } from './types.js'; + +/** + * [#27157] `currency-config-dynamic-default-currency-removed` — the D2 half of + * the `currencyConfig.defaultCurrency` narrowing (ruling 6464202770 on + * #27119, Q2 = X). + * + * Every parse before this change baked `defaultCurrency: 'CNY'` into a + * currency config, so a stored object row carries it on a `dynamic` config + * nobody wrote a code for. The conversion strips it there (no reader takes a + * `dynamic` config's code) and leaves a `fixed` config's code alone (it is the + * currency the field displayed). The fixture pair in `conversions.test.ts` + * proves before → after over the whole table; this file pins it on a STORED + * row, the seam that replays it. + */ + +const ID = 'currency-config-dynamic-default-currency-removed'; +const D3_ID = 'currency-config-fixed-default-currency-required'; + +/** An object row as an earlier parse stored it: every currency config carries a code. */ +function storedObject() { + return { + name: 'billing_invoice', + label: 'Invoice', + fields: { + amount: { name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }, + // `{ currencyMode: 'fixed' }` as authored: the old parse filled in CNY. + legacy_fixed: { name: 'legacy_fixed', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'CNY' } }, + tax: { name: 'tax', type: 'currency', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, + net: { + name: 'net', + type: 'formula', + expression: 'record.amount - record.tax', + returnType: 'currency', + currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' }, + }, + qty: { name: 'qty', type: 'number' }, + }, + }; +} + +function convertStored(row: Record) { + const notices: ConversionNotice[] = []; + const item = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) }); + return { item: item as ReturnType, notices: notices.filter((n) => n.conversionId === ID) }; +} + +describe('currency-config-dynamic-default-currency-removed (ADR-0087 D2)', () => { + it('is registered for protocol 18, retired from the authoring load path, and linked from its D3 entry', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ID); + expect(entry, 'the conversion is registered').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + expect(CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id)).toContain(ID); + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(ID); + const d3 = MIGRATIONS_BY_MAJOR[18]!.semantic.find((s) => s.id === D3_ID); + expect(d3?.conversionIds).toEqual([ID]); + }); + + it('strips the code from every dynamic config of a stored row and keeps every fixed one', () => { + const row = storedObject(); + const { item, notices } = convertStored(row); + expect(item.fields.tax.currencyConfig).toEqual({ currencyMode: 'dynamic' }); + expect(item.fields.net.currencyConfig).toEqual({ currencyMode: 'dynamic' }); + expect(item.fields.amount).toBe(row.fields.amount); + expect(item.fields.legacy_fixed).toBe(row.fields.legacy_fixed); + expect(item.fields.qty).toBe(row.fields.qty); + expect(notices.map((n) => [n.path, n.from, n.to])).toEqual([ + ['objects[0].fields.tax.currencyConfig.defaultCurrency', 'defaultCurrency', '(removed)'], + ['objects[0].fields.net.currencyConfig.defaultCurrency', 'defaultCurrency', '(removed)'], + ]); + }); + + it('a converted row parses to itself: fixed keeps its code, dynamic carries none', () => { + const { item } = convertStored(storedObject()); + const parsed = ObjectSchema.parse(item); + expect(parsed.fields.amount!.currencyConfig).toEqual({ currencyMode: 'fixed', defaultCurrency: 'USD' }); + expect(parsed.fields.legacy_fixed!.currencyConfig).toEqual({ currencyMode: 'fixed', defaultCurrency: 'CNY' }); + expect(parsed.fields.tax!.currencyConfig).toEqual({ currencyMode: 'dynamic' }); + expect(parsed.fields.net!.currencyConfig).toEqual({ currencyMode: 'dynamic' }); + }); + + it('is idempotent: a second replay finds nothing to strip', () => { + const once = convertStored(storedObject()).item; + const twice = convertStored(once); + expect(twice.notices).toEqual([]); + expect(twice.item).toEqual(once); + }); + + it('does not run on the authoring load path: an authored dynamic code stays (legal, unread)', () => { + const stack = { objects: [storedObject()] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(stack, { onNotice: (n) => notices.push(n) }); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + expect((out as typeof stack).objects[0]!.fields.tax.currencyConfig) + .toEqual({ currencyMode: 'dynamic', defaultCurrency: 'CNY' }); + }); +}); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 831ca79e7f8..97b7f8c1f01 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -13622,6 +13622,113 @@ const formLayoutInlineGridToVertical: MetadataConversion = { }, }; +/** + * `currencyConfig.defaultCurrency` on a `dynamic` config → removed (protocol + * 18, #27157 — ruling 6464202770 on #27119, Q2 = X: an amount whose currency + * is not known takes the tenant default, and nothing guesses; the D3 semantic + * entry `currency-config-fixed-default-currency-required`). + * + * `defaultCurrency` was `.default('CNY')`, so EVERY parse wrote a code into + * the config: a `fixed` config with none became a yuan field, and a `dynamic` + * config — which reads no code, its amounts display in the tenant default + * currency — carried a `'CNY'` nobody wrote and every reader had to know to + * ignore. The default is gone, and the parse now refuses `fixed` without a + * code instead of filling one in. + * + * **Why data at rest needs this entry.** Stored `sys_metadata` object rows and + * built artifacts were all written by a parse that baked the `'CNY'` in, so a + * `dynamic` row carries it whether or not anyone wrote it; JSON cannot say + * which. This entry strips it from every config whose mode is `dynamic` (or + * absent, which parses to `dynamic`), so those rows read as a parse of their + * source now does. **Lossless against the runtime:** no reader takes a + * `dynamic` config's code (the console's `resolveFieldCurrency`, the analytics + * field-currency relay and the formula return inference all read it under + * `fixed` only). A `fixed` row is left alone: its code, authored or baked, is + * the currency it displayed, and it satisfies the new refinement. + * + * **Retired from the load path, from day one.** An authored `defaultCurrency` + * on a `dynamic` config is legal and unread, so the authoring funnel does not + * rewrite a source; only stored rows (`applyConversionsToStoredItem`) and a + * built artifact inside its declared-floor window get the strip. Fields are a + * RECORD keyed by name with `currencyConfig` one level down, walked per field + * on objects and object extensions, as `currency-config-precision-removed` + * does. A formula field returning `currency` carries the same key and is + * walked the same way. Idempotent: a stripped config has no `defaultCurrency` + * for the next pass to find. + */ +const currencyConfigDynamicDefaultCurrencyRemoved: MetadataConversion = { + id: 'currency-config-dynamic-default-currency-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.2.0', + surface: 'object.fields.*.currencyConfig.defaultCurrency — under currencyMode: dynamic', + summary: + "currency field key 'currencyConfig.defaultCurrency' removed from a 'dynamic' config " + + '(no reader takes a dynamic config\'s code — amounts display in the tenant default currency; ' + + 'the `\'CNY\'` an earlier parse baked in goes, and a `fixed` config keeps its code)', + apply(stack, emit) { + const stripOn = (input: Dict, collection: string): Dict => + mapCollection(input, collection, (owner, path) => { + const fields = owner.fields; + if (!isDict(fields)) return owner; + let changed = false; + const next: Dict = {}; + for (const [name, def] of Object.entries(fields)) { + const config = isDict(def) ? def.currencyConfig : undefined; + if (!isDict(config) || (config.currencyMode !== undefined && config.currencyMode !== 'dynamic')) { + next[name] = def; + continue; + } + const stripped = stripKeys(config, ['defaultCurrency'], emit, `${path}.fields.${name}.currencyConfig`); + if (stripped === config) { + next[name] = def; + continue; + } + next[name] = { ...(def as Dict), currencyConfig: stripped }; + changed = true; + } + return changed ? { ...owner, fields: next } : owner; + }); + return stripOn(stripOn(stack, 'objects'), 'objectExtensions'); + }, + fixture: { + before: { + objects: [{ + name: 'billing_ledger', + label: 'Ledger', + fields: { + // The baked default on a dynamic config — what a stored row carries + // without anyone having written it. + tax: { type: 'currency', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, + // No mode: parses to dynamic, so its code is unread too. + fee: { type: 'currency', currencyConfig: { defaultCurrency: 'EUR' } }, + // A formula returning currency carries the same shape. + net: { type: 'formula', expression: 'record.fee - record.tax', returnType: 'currency', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, + // Fixed: the code is the field's currency, kept; reference kept. + amount: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }, + // Dynamic with no code: nothing to strip. + credit: { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } }, + }, + }], + }, + after: { + objects: [{ + name: 'billing_ledger', + label: 'Ledger', + fields: { + tax: { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } }, + fee: { type: 'currency', currencyConfig: {} }, + net: { type: 'formula', expression: 'record.fee - record.tax', returnType: 'currency', currencyConfig: { currencyMode: 'dynamic' } }, + amount: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }, + credit: { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } }, + }, + }], + }, + // One per stripped code: `tax`, `fee` and `net`. + expectedNotices: 3, + }, +}; + /** * `currencyConfig.precision` — a declared, validated key no renderer or runtime * ever read (#19992, ADR-0049 enforce-or-remove; triage direction REMOVE under @@ -13703,8 +13810,10 @@ const currencyConfigPrecisionRemoved: MetadataConversion = { currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, }, // The baked default on a dynamic config — what a stored row carries - // without anyone having written it. - tax: { type: 'currency', currencyConfig: { precision: 2, currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, + // without anyone having written it. (Its baked `defaultCurrency` is + // `currency-config-dynamic-default-currency-removed`'s, left out so + // the two fixtures stay disjoint.) + tax: { type: 'currency', currencyConfig: { precision: 2, currencyMode: 'dynamic' } }, // No `precision` under `currencyConfig`: untouched, reference kept. fee: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } }, // The FIELD-level `precision` (total digits) is a different key and @@ -13719,7 +13828,7 @@ const currencyConfigPrecisionRemoved: MetadataConversion = { label: 'Invoice', fields: { amount: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }, - tax: { type: 'currency', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, + tax: { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } }, fee: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } }, quantity: { type: 'number', precision: 10, scale: 0 }, }, @@ -14294,6 +14403,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: cubeMemberInnerNameRemoved, order: 44 }, { conversion: cubeRefreshKeyRemoved, order: 47 }, { conversion: cubeSubDayGranularitiesRemoved, order: 8 }, + { conversion: currencyConfigDynamicDefaultCurrencyRemoved, order: 78 }, { conversion: currencyConfigPrecisionRemoved, order: 41 }, { conversion: dashboardRefreshIntervalToRefreshIntervalSeconds, order: 24 }, { conversion: dashboardWidgetChartConfigStructureRemoved, order: 33 }, diff --git a/packages/spec/src/data/currency-config-fixed-code-required.test.ts b/packages/spec/src/data/currency-config-fixed-code-required.test.ts new file mode 100644 index 00000000000..6795f0bdd61 --- /dev/null +++ b/packages/spec/src/data/currency-config-fixed-code-required.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { CurrencyConfigSchema, FieldSchema } from './field.zod'; + +/** + * [#27157] `currencyConfig.defaultCurrency` has no default (ruling 6464202770 + * on #27119, Q2 = X: an amount whose currency is not known takes the tenant + * default, and nothing guesses). + * + * Before: `{ currencyMode: 'fixed' }` parsed to `defaultCurrency: 'CNY'` (a + * yuan field nobody wrote), and every `dynamic` config's output carried that + * `'CNY'` too. Now `fixed` without a code is refused with the prescription, at + * every place the shape is embedded (a currency field and a formula returning + * `currency` share it), and `dynamic` output carries no code it was not given. + */ + +/** The refusal's first sentence: the prescription an author reads. */ +const FIRST_SENTENCE = + "`currencyConfig.currencyMode: 'fixed'` needs `defaultCurrency`, the field's one currency: " + + "give its ISO 4217 code (e.g. `defaultCurrency: 'USD'`), or use `currencyMode: 'dynamic'` " + + 'to display amounts in the tenant default currency (the `localization.currency` setting).'; + +function refusal(result: { success: boolean; error?: { issues: ReadonlyArray<{ code: string; path: PropertyKey[]; message: string }> } }) { + expect(result.success, 'refused').toBe(false); + return result.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), prescribes: i.message.startsWith(FIRST_SENTENCE) })); +} + +describe('currencyConfig — `fixed` needs its code (#27157)', () => { + it('refuses `fixed` without a code, at `defaultCurrency`, with the prescription', () => { + expect(refusal(CurrencyConfigSchema.safeParse({ currencyMode: 'fixed' }))).toEqual([ + { code: 'custom', path: 'defaultCurrency', prescribes: true }, + ]); + }); + + it('refuses it on a currency field', () => { + const r = FieldSchema.safeParse({ name: 'amount', label: 'Amount', type: 'currency', currencyConfig: { currencyMode: 'fixed' } }); + expect(refusal(r)).toEqual([{ code: 'custom', path: 'currencyConfig.defaultCurrency', prescribes: true }]); + }); + + it('refuses it on a formula field returning currency, the same shape (#22727)', () => { + const r = FieldSchema.safeParse({ + name: 'net', label: 'Net', type: 'formula', expression: 'record.amount - record.tax', returnType: 'currency', + currencyConfig: { currencyMode: 'fixed' }, + }); + expect(refusal(r)).toEqual([{ code: 'custom', path: 'currencyConfig.defaultCurrency', prescribes: true }]); + }); + + it('passes `fixed` with a code unchanged', () => { + for (const code of ['USD', 'JPY', 'CNY', 'BTC']) { + const config = { currencyMode: 'fixed' as const, defaultCurrency: code }; + expect(CurrencyConfigSchema.parse(config)).toEqual(config); + const field = FieldSchema.parse({ name: 'amount', label: 'Amount', type: 'currency', currencyConfig: config }); + expect(field.currencyConfig).toEqual(config); + } + }); + + it('gives a `dynamic` config no code it was not given', () => { + for (const input of [{}, { currencyMode: 'dynamic' as const }]) { + const out = CurrencyConfigSchema.parse(input); + expect(out).toEqual({ currencyMode: 'dynamic' }); + expect('defaultCurrency' in out, JSON.stringify(input)).toBe(false); + } + // An authored code under `dynamic` is legal and unread; it is returned as written. + expect(CurrencyConfigSchema.parse({ currencyMode: 'dynamic', defaultCurrency: 'EUR' })) + .toEqual({ currencyMode: 'dynamic', defaultCurrency: 'EUR' }); + }); + + it('parse(parse(x)) is parse(x) for every accepted shape', () => { + const configs = [ + {}, { currencyMode: 'dynamic' }, { currencyMode: 'dynamic', defaultCurrency: 'EUR' }, + { defaultCurrency: 'GBP' }, { currencyMode: 'fixed', defaultCurrency: 'USD' }, + ]; + for (const config of configs) { + const once = CurrencyConfigSchema.parse(config); + expect(CurrencyConfigSchema.parse(once), JSON.stringify(config)).toEqual(once); + const field = FieldSchema.parse({ name: 'amount', label: 'Amount', type: 'currency', currencyConfig: config }); + expect(FieldSchema.parse(field), JSON.stringify(config)).toEqual(field); + } + }); +}); diff --git a/packages/spec/src/data/currency-precision-iso4217.test.ts b/packages/spec/src/data/currency-precision-iso4217.test.ts index dbdbe0089ed..316092c6bcc 100644 --- a/packages/spec/src/data/currency-precision-iso4217.test.ts +++ b/packages/spec/src/data/currency-precision-iso4217.test.ts @@ -124,7 +124,8 @@ describe('`currencyConfig.precision` is removed: refused with the prescription, // front on every row except the #11423 guarded class (bare fixed JPY/KRW/ // KWD), which is why stored rows carry it without anyone writing it. const cases: Array<[Record, string]> = [ - [{}, '{"currencyMode":"dynamic","defaultCurrency":"CNY"}'], + // #27157: no baked `'CNY'` either — the empty config gets its mode only. + [{}, '{"currencyMode":"dynamic"}'], [{ currencyMode: 'fixed', defaultCurrency: 'USD' }, '{"currencyMode":"fixed","defaultCurrency":"USD"}'], [{ currencyMode: 'fixed', defaultCurrency: 'JPY' }, '{"currencyMode":"fixed","defaultCurrency":"JPY"}'], [{ currencyMode: 'fixed', defaultCurrency: 'KWD' }, '{"currencyMode":"fixed","defaultCurrency":"KWD"}'], @@ -188,7 +189,9 @@ describe('the removed `currencyConfig.precision` at rest: a stored row carrying onNotice: (n) => notices.push(`${n.conversionId}@${n.path}`), }); expect(converted.fields.amount.currencyConfig).toEqual({ currencyMode: 'fixed', defaultCurrency: 'USD' }); - expect(converted.fields.tax.currencyConfig).toEqual({ currencyMode: 'dynamic', defaultCurrency: 'CNY' }); + // The baked `'CNY'` on the dynamic config goes too, by the later major-18 + // entry `currency-config-dynamic-default-currency-removed` (#27157). + expect(converted.fields.tax.currencyConfig).toEqual({ currencyMode: 'dynamic' }); // The FIELD-level total-digit count is a different key and survives. expect(converted.fields.qty).toEqual({ label: 'Qty', type: 'number', precision: 10, scale: 0 }); expect(notices.filter((n) => n.startsWith('currency-config-precision-removed@'))).toHaveLength(2); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index f40ee308f2f..c2f21f71e9a 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -97,8 +97,9 @@ export const fieldForm = defineForm({ // values, so it takes the `object.form.ts` `access` / `lifecycle` face: a // composite whose sub-rows are DECLARED, so their labels and help text // reach the translation catalogs (a schema-derived sub-field never does). - // Schema defaults (`dynamic`, `CNY`) are placeholders the renderer never - // writes on mount, so an untouched composite stays absent. + // The schema default (`dynamic`) is a placeholder the renderer never + // writes on mount, so an untouched composite stays absent. A code has no + // default (#27157): `fixed` without one is refused, not filled in. // #22727 — a formula returning `currency` carries its currency in this // same key, so the row shows for it too; the gate is the returnType // row's own value, not a second control. @@ -112,7 +113,7 @@ export const fieldForm = defineForm({ { label: 'Dynamic — the tenant default currency', value: 'dynamic' }, { label: 'Fixed — one currency for this field', value: 'fixed' }, ] }, - { field: 'defaultCurrency', type: 'text', helpText: 'The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Defaults to CNY. Not read in dynamic mode.' }, + { field: 'defaultCurrency', type: 'text', helpText: 'The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Required in fixed mode; nothing fills one in. Not read in dynamic mode.' }, ], }, // Every `visibleWhen` below is a MEANINGFULNESS gate, not a parse gate: diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index b013d3c705d..c6f0efecf63 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -80,12 +80,12 @@ describe('CurrencyConfigSchema', () => { expect(() => CurrencyConfigSchema.parse(validConfig)).not.toThrow(); }); - it('should apply default values — and materialize no decimal-places key', () => { + it('should apply the mode default — and materialize no decimal-places key and no currency code', () => { const config = CurrencyConfigSchema.parse({}); // Byte-exact: the removed `.overwrite()` used to bake `precision: 2` in - // front of these two keys. - expect(JSON.stringify(config)).toBe('{"currencyMode":"dynamic","defaultCurrency":"CNY"}'); + // front of the keys, and the removed `.default('CNY')` (#27157) a code. + expect(JSON.stringify(config)).toBe('{"currencyMode":"dynamic"}'); }); it('refuses `precision` at EVERY value, in range or not: the key was removed, not re-bounded', () => { @@ -109,7 +109,7 @@ describe('CurrencyConfigSchema', () => { it('should accept both currency modes', () => { expect(() => CurrencyConfigSchema.parse({ currencyMode: 'dynamic' })).not.toThrow(); - expect(() => CurrencyConfigSchema.parse({ currencyMode: 'fixed' })).not.toThrow(); + expect(() => CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'USD' })).not.toThrow(); }); it('should reject invalid currency modes', () => { @@ -1753,8 +1753,8 @@ describe('Field Factory Helpers', () => { }; const result = FieldSchema.parse(field); - // #19992: exactly the two keys — no baked `precision: 2` any more. - expect(result.currencyConfig).toEqual({ currencyMode: 'dynamic', defaultCurrency: 'CNY' }); + // #19992: no baked `precision: 2` any more; #27157: no baked `'CNY'`. + expect(result.currencyConfig).toEqual({ currencyMode: 'dynamic' }); }); it('refuses a currencyConfig `precision` of any value as an unknown key, not as a bad number', () => { @@ -1814,6 +1814,7 @@ describe('Field Factory Helpers', () => { type: 'currency' as const, currencyConfig: { currencyMode: 'fixed' as const, + defaultCurrency: 'EUR', }, }; diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 2777cf65b3c..d5ee4b6a327 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -534,6 +534,27 @@ const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly> + ' Delete the key.', }; +/** + * The refusal a `fixed` currency config without a code meets (#27157, ruling + * 6464202770 on #27119, Q2 = X). Until this ruling the schema filled the code + * in with `'CNY'`, so the field silently became a yuan field. + */ +const CURRENCY_CONFIG_FIXED_WITHOUT_CODE = + '`currencyConfig.currencyMode: \'fixed\'` needs `defaultCurrency`, the field\'s one currency: ' + + 'give its ISO 4217 code (e.g. `defaultCurrency: \'USD\'`), or use `currencyMode: \'dynamic\'` ' + + 'to display amounts in the tenant default currency (the `localization.currency` setting). ' + + 'Nothing fills a code in.'; + +/** `fixed` ⇒ an authored `defaultCurrency`; `dynamic` reads none, so it needs none. */ +function refuseFixedCurrencyWithoutCode( + config: { currencyMode: 'dynamic' | 'fixed'; defaultCurrency?: string }, + ctx: z.RefinementCtx, +): void { + if (config.currencyMode === 'fixed' && config.defaultCurrency === undefined) { + ctx.addIssue({ code: 'custom', path: ['defaultCurrency'], message: CURRENCY_CONFIG_FIXED_WITHOUT_CODE }); + } +} + /** * Currency Configuration Schema * Configuration for currency field type supporting multi-currency — and, in the @@ -548,6 +569,19 @@ const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly> * There is no decimal-places key: a currency's decimal places are its ISO 4217 * minor unit (see {@link CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE} for the * removed `precision`). + * + * `defaultCurrency` has no default (#27157, ruling 6464202770 on #27119, Q2 = + * X: an amount whose currency is not known takes the tenant default, and + * nothing guesses). It used to be `.default('CNY')`, so a `fixed` config with + * no code became a yuan field without anyone writing yuan, and every + * `dynamic` config's parse output carried a `'CNY'` its readers had to know to + * ignore — a default the #9689 principle forbids materializing. A `fixed` + * config now needs its code ({@link CURRENCY_CONFIG_FIXED_WITHOUT_CODE}); a + * `dynamic` one parses to output with no `defaultCurrency` unless one was + * written. Stored rows and built artifacts that carry the baked `'CNY'` on a + * `dynamic` config are stripped on rehydration by the ADR-0087 conversion + * `currency-config-dynamic-default-currency-removed`; a `fixed` row keeps its + * code, which was the live value. */ export const CurrencyConfigSchema = lazySchema(() => strictObject({ surface: 'this currency configuration', @@ -556,13 +590,13 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({ guidance: CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE, }, { currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('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: z.string().length(3).default('CNY').describe('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.'), - // #19992 — no `.superRefine()` / `.overwrite()` here any more. The #7918 - // ISO 4217 contradiction check and the #11423 default-materializing - // `.overwrite()` both existed only for the removed `precision` key (see - // CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE above), so parse output is now - // exactly `{ currencyMode, defaultCurrency }`. -})); + defaultCurrency: z.string().length(3).optional().describe('The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency.'), + // #19992 — no `.overwrite()` here any more: the #7918 ISO 4217 contradiction + // check and the #11423 default-materializing `.overwrite()` both existed only + // for the removed `precision` key (see CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE + // above). #27157 — the one refinement left is `fixed` ⇒ a code, below; parse + // output is `{ currencyMode }` plus the `defaultCurrency` the author wrote. +}).superRefine(refuseFixedCurrencyWithoutCode)); /** * Currency Value Schema diff --git a/packages/spec/src/data/formula-currency-result.test.ts b/packages/spec/src/data/formula-currency-result.test.ts index 260dc622018..96be4afebe1 100644 --- a/packages/spec/src/data/formula-currency-result.test.ts +++ b/packages/spec/src/data/formula-currency-result.test.ts @@ -57,7 +57,7 @@ describe('a formula declares a currency result', () => { }); it('carries its currency in its own `currencyConfig`, parsed exactly as a currency field\'s', () => { - for (const config of [FIXED_USD, { currencyMode: 'dynamic' }, { currencyMode: 'fixed' }, {}]) { + for (const config of [FIXED_USD, { currencyMode: 'dynamic' }, {}]) { const asFormula = FieldSchema.parse(currencyFormula({ currencyConfig: config })); const asField = FieldSchema.parse(currencyField({ currencyConfig: config })); expect(asFormula.currencyConfig, JSON.stringify(config)).toEqual(asField.currencyConfig); @@ -70,6 +70,8 @@ describe('a formula declares a currency result', () => { const malformed = [ { currencyMode: 'fixed', defaultCurrency: 'US' }, { currencyMode: 'per_record' }, + // `fixed` with no code: refused, never filled in with a code (#27157). + { currencyMode: 'fixed' }, // The removed decimal-places key keeps its tombstone answer on a formula too. { ...FIXED_USD, precision: 2 }, ]; diff --git a/packages/spec/src/migrations/entries/semantic/18.currency-config-fixed-default-currency-required.ts b/packages/spec/src/migrations/entries/semantic/18.currency-config-fixed-default-currency-required.ts new file mode 100644 index 00000000000..ee28443a614 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.currency-config-fixed-default-currency-required.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #27157 (ruling 6464202770 on #27119, Q2 = X). A tightened requirement: a +// fixed currency config needs its code, and the CNY the schema used to fill in +// is gone. The data-at-rest half is the D2 conversion +// `currency-config-dynamic-default-currency-removed`, retired from the load +// path from day one: stored rows and older built artifacts lose the code a +// dynamic config never read, and a fixed row keeps the code it displayed. +export const entry: SemanticMigration = { + id: 'currency-config-fixed-default-currency-required', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + "field.currencyConfig (a currency field, or a formula field with returnType: 'currency') with " + + "currencyMode: 'fixed' and no defaultCurrency, which parsed to defaultCurrency: 'CNY'", + replacement: + 'Give a fixed config its currency: `currencyConfig: { currencyMode: \'fixed\', defaultCurrency: ' + + '\'USD\' }` (any three-character code; ISO 4217 for a real currency). Or, when the field should ' + + 'show amounts in the tenant default currency (the `localization.currency` setting), use ' + + '`currencyMode: \'dynamic\'`, or omit `currencyConfig`: dynamic is the default.', + reason: + 'Maintainer ruling X, 2026-10-28: an amount whose currency is not known takes the tenant default, ' + + 'and nothing guesses. `defaultCurrency` was `.default(\'CNY\')`, so a fixed config written without ' + + 'a code became a yuan field and displayed ¥ in a dollar or euro app, and every dynamic config\'s ' + + 'parse output carried a CNY its readers had to know to ignore (a default the schema materialized ' + + 'on every parse). The default is removed: a fixed config without a code is refused at every door ' + + 'that parses a field, at the defaultCurrency path, with the prescription, and a dynamic config ' + + 'parses to output with no defaultCurrency unless one was written. STORED ROWS AND OLDER BUILT ' + + 'ARTIFACTS: every earlier parse baked the code in, so no stored fixed row lacks one; it keeps the ' + + 'code it displayed. The D2 conversion `currency-config-dynamic-default-currency-removed` strips ' + + 'the unread code from dynamic rows on rehydration and inside an artifact\'s declared-floor ' + + 'window, which changes no display. ADR-0049 / ADR-0087.', + conversionIds: ['currency-config-dynamic-default-currency-removed'], + acceptanceCriteria: + 'Search every authored object, object extension and field definition for a `currencyConfig` with ' + + '`currencyMode: \'fixed\'` and no `defaultCurrency`, and write the code the field is in (it ' + + 'displayed CNY until now), or switch it to dynamic. `FieldSchema.safeParse` answers one `custom` ' + + 'issue at `currencyConfig.defaultCurrency` for each such field. The repository carried no fixed ' + + 'config without a code at the time of the change (examples, platform objects, seeds and fixtures ' + + 'surveyed).', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 9645c1ad7c4..a110d81cf5a 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1595,6 +1595,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'semantic entry `cube-refresh-key-retired`. A refresh cadence is declared again when a ' + 'result cache exists.', }, + { + id: 'currency-config-fixed-default-currency-required', + order: 112, + text: + 'It also removes the `\'CNY\'` default of `currencyConfig.defaultCurrency` (maintainer ' + + 'ruling X: an amount whose currency is not known takes the tenant default, and nothing ' + + 'guesses). A `fixed` config without a code is refused with the prescription instead of ' + + 'becoming a yuan field, and a `dynamic` config parses to output with no code. The D2 ' + + 'conversion `currency-config-dynamic-default-currency-removed` strips the unread code an ' + + 'earlier parse baked into `dynamic` stored rows and built artifacts; a `fixed` row keeps ' + + 'its code. Retired from the load path.', + }, { id: 'currency-config-precision-retired', order: 41, diff --git a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts index 6a3e1d7fc81..ca5ff20e760 100644 --- a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts +++ b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts @@ -74,7 +74,7 @@ describe('currencyMode decides whether currencyConfig.defaultCurrency is read (o // meaning "whatever the spec materializes for an empty config". const parsed = CurrencyConfigSchema.parse({}); expect(parsed.currencyMode, 'the spec defaults an empty config to dynamic').toBe('dynamic'); - expect(parsed.defaultCurrency, 'the spec fills in a code of its own').not.toBe(TENANT); + expect(parsed.defaultCurrency, 'the spec fills in no code (#27157)').toBeUndefined(); expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD'); }); diff --git a/ui/packages/i18n/src/currency.ts b/ui/packages/i18n/src/currency.ts index f192e57d0f8..4db07c7bc3a 100644 --- a/ui/packages/i18n/src/currency.ts +++ b/ui/packages/i18n/src/currency.ts @@ -31,11 +31,13 @@ * `FieldSchema` guidance says a field without a fixed currency "uses the tenant * default at runtime". No value carries a user-selected code today (a currency * value is a bare number), so a dynamic field reads the tenant default, never - * its `defaultCurrency`. That matters beyond authored dynamic configs: the - * spec parses an EMPTY `currencyConfig` to - * `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, which must not render - * as yuan. If a value ever carries its own selected code, that code is the one - * to win, ahead of the tenant default. + * its `defaultCurrency`. That matters beyond authored dynamic configs: until + * @objectstack/spec dropped the `'CNY'` default (#27157), the spec parsed an + * EMPTY `currencyConfig` to `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, + * and rows stored or built by that parse still carry it until a rehydration + * strips it; it must not render as yuan. A `fixed` config now always carries + * its code (the spec refuses one without). If a value ever carries its own + * selected code, that code is the one to win, ahead of the tenant default. * * This is the single resolution every field / measure / cell renderer shares - * ending the per-renderer drift where some read only `currency`, others only From e1b5d92de92851a7f8a74209b281a5cd9539b127 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:31:06 +0000 Subject: [PATCH 2/5] chore(spec): regenerate artifacts for the currencyConfig narrowing Declares the defaultCurrency default change (CNY -> none) under major 18, the dropped-refinement sites the new superRefine adds, and the regenerated authorable defaults and reference docs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh --- content/docs/references/data/field.mdx | 4 ++-- packages/spec/authorable-defaults/data.json | 1 - packages/spec/dropped-refinements.baseline.json | 17 +++++++++++++++++ packages/spec/scripts/lib/default-changes.ts | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index c319e88e320..bfca6a80361 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -29,7 +29,7 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **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"`) | 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. | +| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | --- @@ -249,7 +249,7 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **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"`) | 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. | +| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | ### Nested Shape: `Field.conditionalFormatting[number]` diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index a47a7d1fea5..cbf195cdae1 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -12,7 +12,6 @@ "data/CrossFieldValidation:severity = \"error\"", "data/Cube:public = true", "data/CurrencyConfig:currencyMode = \"dynamic\"", - "data/CurrencyConfig:defaultCurrency = \"CNY\"", "data/DataEngineDeleteOptions:multi = false", "data/DataEngineInsertOptions:returning = true", "data/DataEngineUpdateOptions:multi = false", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index e0da8fefe65..008e051060a 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -105,6 +105,7 @@ "manifest.objects.element.defaultRowScope", "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.objects.element.membershipKeys", @@ -234,6 +235,7 @@ "data.options[1].manifest.objects.element.defaultRowScope", "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.objects.element.membershipKeys", @@ -362,6 +364,7 @@ "options[1].manifest.objects.element.defaultRowScope", "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.objects.element.membershipKeys", @@ -433,6 +436,7 @@ "data.packages.element.options[1].manifest.objects.element.defaultRowScope", "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.objects.element.membershipKeys", @@ -485,6 +489,7 @@ "data.defaultRowScope", "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", @@ -756,6 +761,11 @@ "measures.valueType" ] }, + "data/CurrencyConfig": { + "sites": [ + "" + ] + }, "data/DataEngineAggregateOptions": { "sites": [ "filter.options[1].lazy" @@ -875,6 +885,7 @@ "data/Field": { "sites": [ "", + "currencyConfig", "inlineColumns.element", "relatedListFilter.lazy" ] @@ -977,6 +988,7 @@ "defaultRowScope", "fieldGroups", "fields.out.valueType", + "fields.out.valueType.currencyConfig", "fields.out.valueType.inlineColumns.element", "fields.out.valueType.relatedListFilter.lazy", "lifecycle", @@ -996,6 +1008,7 @@ "sites": [ "", "fields.valueType", + "fields.valueType.currencyConfig", "fields.valueType.inlineColumns.element", "fields.valueType.relatedListFilter.lazy" ] @@ -1276,6 +1289,7 @@ "system/AddFieldOperation": { "sites": [ "field", + "field.currencyConfig", "field.inlineColumns.element", "field.relatedListFilter.lazy" ] @@ -1298,6 +1312,7 @@ "system/ChangeSet": { "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", @@ -1331,6 +1346,7 @@ "object.defaultRowScope", "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", @@ -1370,6 +1386,7 @@ "system/MigrationOperation": { "sites": [ "options[0].field", + "options[0].field.currencyConfig", "options[0].field.inlineColumns.element", "options[0].field.relatedListFilter.lazy", "options[3].object", diff --git a/packages/spec/scripts/lib/default-changes.ts b/packages/spec/scripts/lib/default-changes.ts index c0c892aff49..5a9808688a6 100644 --- a/packages/spec/scripts/lib/default-changes.ts +++ b/packages/spec/scripts/lib/default-changes.ts @@ -621,6 +621,21 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly Date: Wed, 28 Oct 2026 09:56:21 +0000 Subject: [PATCH 3/5] test(spec): pin the currency refusal's release and the ledger totals The fixed-without-code refusal names the release it ships in, joined in the retirement-release pin for the new D2 entry; the dropped-refinements header totals follow the 13 new sites. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh --- packages/spec/dropped-refinements.baseline.json | 4 ++-- packages/spec/src/data/field.zod.ts | 2 +- .../src/shared/retired-key-removal-version.pin.test.ts | 10 ++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 008e051060a..8770d6cf062 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": 251, - "droppedRefinementSites": 887, + "publishedSchemasWithDroppedRefinements": 252, + "droppedRefinementSites": 900, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index d5ee4b6a327..15dc7e6c014 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -543,7 +543,7 @@ const CURRENCY_CONFIG_FIXED_WITHOUT_CODE = '`currencyConfig.currencyMode: \'fixed\'` needs `defaultCurrency`, the field\'s one currency: ' + 'give its ISO 4217 code (e.g. `defaultCurrency: \'USD\'`), or use `currencyMode: \'dynamic\'` ' + 'to display amounts in the tenant default currency (the `localization.currency` setting). ' - + 'Nothing fills a code in.'; + + 'Since @objectstack/spec 18 (ADR-0049) nothing fills a code in; it used to be `\'CNY\'`.'; /** `fixed` ⇒ an authored `defaultCurrency`; `dynamic` reads none, so it needs none. */ function refuseFixedCurrencyWithoutCode( diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 939018d9cfb..50db4f3bbd1 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -77,6 +77,7 @@ import { describe, expect, it } from 'vitest'; import { ALL_CONVERSIONS } from '../conversions/registry'; import { TimeUpdateInterval } from '../data/analytics.zod'; +import { CurrencyConfigSchema } from '../data/field.zod'; import { IndexSchema, ObjectSchema } from '../data/object.zod'; import { ManifestPermissionsSchema } from '../kernel/manifest.zod'; import { @@ -251,6 +252,15 @@ const JOIN: Readonly> = { tombstone(AdminScopeSchema, 'permission.adminScope', 'authorEnvironmentSets'), ], 'permission-set-managed-by-removed': [tombstone(PermissionSetSchema, 'permission', 'managedBy')], + // #27157: the D2 strip is the data-at-rest half; what an author meets is the + // refusal of a `fixed` config with no code, which names the release. + 'currency-config-dynamic-default-currency-removed': [ + refusal( + "currencyConfig.currencyMode 'fixed' without defaultCurrency", + () => CurrencyConfigSchema.safeParse({ currencyMode: 'fixed' }), + 'defaultCurrency', + ), + ], 'permission-modify-all-allow-delete-false-to-true': [ refusal( 'allowDelete: false beside modifyAllRecords: true', From c172c878ec3164e8bd361fcdb5cc4039336eda7c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:43:58 +0000 Subject: [PATCH 4/5] docs(changeset): the survey counts 11 fixed currency configs (CHANGELOGs excluded) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh --- .changeset/27157-currency-config-fixed-code-required.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/27157-currency-config-fixed-code-required.md b/.changeset/27157-currency-config-fixed-code-required.md index 3b5813fac81..32fe7c1156d 100644 --- a/.changeset/27157-currency-config-fixed-code-required.md +++ b/.changeset/27157-currency-config-fixed-code-required.md @@ -21,4 +21,4 @@ Metadata-protocol: narrowed - **Readers.** None changes what it displays. The console's `resolveFieldCurrency` and the analytics field-currency relay already read the code only under `fixed`. `@objectstack/formula`'s return-type inference reads a field's currency through `CurrencyConfigSchema`; a `fixed` config without a code no longer parses there, so it reads as an unproven currency instead of CNY. - **`@objectstack/platform-objects`.** The field form's `defaultCurrency` help text says the code is required in fixed mode instead of "Defaults to CNY", in en, zh-CN, ja-JP and es-ES. -Survey at the change: 17 `fixed` currency configs across `examples/**`, `packages/platform-objects/**`, `packages/qa/**`, seeds and fixtures, none without a code. +Survey at the change: 11 `fixed` currency configs across `examples/**`, `packages/platform-objects/**`, `packages/qa/**`, seeds and fixtures, none without a code. From a7683e16f04b033a608a5bb8fdfe8cb03e3619ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:28:44 +0000 Subject: [PATCH 5/5] fix(spec): state what a stored fixed currency row without a code does (#27157) Contract review round 2. A sys_metadata object row is kept as authored, so a row saved as { currencyMode: 'fixed' } has no code; the conversion writes none in. It loads and registers unchanged, is counted invalid with a [metadata_spec_invalid] line, and its next save is refused until a code is written; its display (the tenant default) does not change. The changeset, the D3 entry, the default-changes row, the conversion docblock, the field docblock, the upgrade-guide fragment and the console currency comment now say so, and the conversion test pins the row. The refusal drops its ADR-0049 citation (the narrowing is ruling-driven). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh --- ...157-currency-config-fixed-code-required.md | 2 +- packages/spec/scripts/lib/default-changes.ts | 10 ++-- ...g-dynamic-default-currency-removed.test.ts | 47 +++++++++++++++---- packages/spec/src/conversions/registry.ts | 38 ++++++++++----- packages/spec/src/data/field.zod.ts | 7 +-- ...-config-fixed-default-currency-required.ts | 24 +++++++--- .../spec/src/migrations/registry.ts.template | 5 +- ui/packages/i18n/src/currency.ts | 9 ++-- 8 files changed, 102 insertions(+), 40 deletions(-) diff --git a/.changeset/27157-currency-config-fixed-code-required.md b/.changeset/27157-currency-config-fixed-code-required.md index 32fe7c1156d..4c75e5c8d4d 100644 --- a/.changeset/27157-currency-config-fixed-code-required.md +++ b/.changeset/27157-currency-config-fixed-code-required.md @@ -17,7 +17,7 @@ Metadata-protocol: narrowed - **Now.** A `fixed` config without a code is refused at every door that parses a field (`defineStack`, `os validate`, the object save door), with one `custom` issue at `currencyConfig.defaultCurrency`: "`currencyConfig.currencyMode: 'fixed'` needs `defaultCurrency`, the field's one currency: give its ISO 4217 code (e.g. `defaultCurrency: 'USD'`), or use `currencyMode: 'dynamic'` to display amounts in the tenant default currency (the `localization.currency` setting)." A `dynamic` config (and an empty one) parses to `{ currencyMode: 'dynamic' }`: no code is materialized. An authored code under `dynamic` is still accepted and still unread. - **FROM → TO.** `currencyConfig: { currencyMode: 'fixed' }` → `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }` (the code the field is in; until now it displayed CNY), or `currencyMode: 'dynamic'` / no `currencyConfig` for the tenant default currency. - **The one-line fix.** Write the code next to `currencyMode: 'fixed'`. -- **Stored rows and built artifacts (ADR-0087).** Every earlier parse baked the code in, so no stored `fixed` row lacks one: it keeps its code, which is the currency it displayed. The D2 conversion `currency-config-dynamic-default-currency-removed` strips the unread code from `dynamic` configs (and configs with no mode) on stored-row rehydration and inside a built artifact's declared-floor window, on objects and object extensions; no display changes. It is retired from the load path, so an authored source is never rewritten. The upgrade guide entry is `currency-config-fixed-default-currency-required`. +- **Stored rows and built artifacts (ADR-0087).** A built artifact holds the parsed stack, so its `fixed` configs carry the code the old parse filled in and keep it. A `sys_metadata` object row is stored as authored, so a row saved as `{ currencyMode: 'fixed' }` (Studio's Fixed mode with the code left blank, or a `/meta` write) has no code, and nothing converts one in: guessing `'CNY'` is what the ruling forbids. Such a row still loads and is registered unchanged; boot counts it `invalid` and prints `[metadata_spec_invalid] stored object/NAME fails the current spec schema even after conversion (fields.FIELD.currencyConfig.defaultCurrency: …)`, and the next save of that object is refused (422 `INVALID_METADATA` at `fields.FIELD.currencyConfig.defaultCurrency`) until the code is written. Its display does not change: the registry already held the row as stored, and the console reads a `fixed` code only when there is one, so it showed the tenant default before and still does. `@objectstack/formula` now reads a formula over that field as an unproven currency instead of fixed CNY. The D2 conversion `currency-config-dynamic-default-currency-removed` strips the unread code from `dynamic` configs (and configs with no mode) on stored-row rehydration and inside a built artifact's declared-floor window, on objects and object extensions; no display changes. It is retired from the load path, so an authored source is never rewritten. The upgrade guide entry is `currency-config-fixed-default-currency-required`. - **Readers.** None changes what it displays. The console's `resolveFieldCurrency` and the analytics field-currency relay already read the code only under `fixed`. `@objectstack/formula`'s return-type inference reads a field's currency through `CurrencyConfigSchema`; a `fixed` config without a code no longer parses there, so it reads as an unproven currency instead of CNY. - **`@objectstack/platform-objects`.** The field form's `defaultCurrency` help text says the code is required in fixed mode instead of "Defaults to CNY", in en, zh-CN, ja-JP and es-ES. diff --git a/packages/spec/scripts/lib/default-changes.ts b/packages/spec/scripts/lib/default-changes.ts index 5a9808688a6..dc5548974fd 100644 --- a/packages/spec/scripts/lib/default-changes.ts +++ b/packages/spec/scripts/lib/default-changes.ts @@ -631,9 +631,13 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly expect((out as typeof stack).objects[0]!.fields.tax.currencyConfig) .toEqual({ currencyMode: 'dynamic', defaultCurrency: 'CNY' }); }); + + it('leaves a stored fixed row with no code unconverted; the current schema refuses it with the prescription', () => { + // A sys_metadata row is kept as authored (saveMetaItem stores the payload + // verbatim), so `{ currencyMode: 'fixed' }` saved before spec 18 has no + // code. No code is guessed: on load the row registers as it is, counted + // invalid with a `[metadata_spec_invalid]` line at this path, and its next + // save is refused at the same path until a code is written. + const row = { + name: 'billing_invoice', + label: 'Invoice', + fields: { + amount: { name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'fixed' } }, + }, + }; + const before = structuredClone(row); + const { item, notices } = convertStored(row); + expect(notices).toEqual([]); + expect(item).toEqual(before); + expect((item.fields.amount as { currencyConfig: unknown }).currencyConfig).toEqual({ currencyMode: 'fixed' }); + + const result = ObjectSchema.safeParse(item); + expect(result.success).toBe(false); + const issues = result.success ? [] : result.error.issues; + expect(issues.map((i) => [i.path.join('.'), i.code])).toEqual([ + ['fields.amount.currencyConfig.defaultCurrency', 'custom'], + ]); + expect(issues[0]!.message).toMatch(/give its ISO 4217 code .* or use `currencyMode: 'dynamic'`/); + expect(issues[0]!.message).not.toContain('ADR-0049'); + }); }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 97b7f8c1f01..a66e0d89893 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -13635,16 +13635,28 @@ const formLayoutInlineGridToVertical: MetadataConversion = { * ignore. The default is gone, and the parse now refuses `fixed` without a * code instead of filling one in. * - * **Why data at rest needs this entry.** Stored `sys_metadata` object rows and - * built artifacts were all written by a parse that baked the `'CNY'` in, so a - * `dynamic` row carries it whether or not anyone wrote it; JSON cannot say - * which. This entry strips it from every config whose mode is `dynamic` (or - * absent, which parses to `dynamic`), so those rows read as a parse of their - * source now does. **Lossless against the runtime:** no reader takes a - * `dynamic` config's code (the console's `resolveFieldCurrency`, the analytics - * field-currency relay and the formula return inference all read it under - * `fixed` only). A `fixed` row is left alone: its code, authored or baked, is - * the currency it displayed, and it satisfies the new refinement. + * **Why data at rest needs this entry.** A built artifact holds the parsed + * stack, so every `dynamic` config in it carries the baked `'CNY'`; a stored + * `sys_metadata` object row is kept as authored, so its `dynamic` config may + * carry a code (written, or copied from a parsed body) or none. JSON cannot + * say which code was written. This entry strips it from every config whose + * mode is `dynamic` (or absent, which parses to `dynamic`), so those rows read + * as a parse of their source now does. **Lossless against the runtime:** no + * reader takes a `dynamic` config's code (the console's `resolveFieldCurrency`, + * the analytics field-currency relay and the formula return inference all + * read it under `fixed` only). + * + * **A `fixed` config is never touched.** One with a code keeps it (in an + * artifact, the code the old parse filled in). A stored row saved as + * `{ currencyMode: 'fixed' }` has NO code, because the row is kept as + * authored, and this entry does not write one: a guessed `'CNY'` is exactly + * what the ruling forbids. That row loads and registers unchanged, is counted + * invalid with a `[metadata_spec_invalid]` boot line at + * `fields..currencyConfig.defaultCurrency`, and its next save is refused + * with the prescription. Its display does not change (the console reads a + * `fixed` code only when one is present, so it showed the tenant default + * before too); `@objectstack/formula` reads a formula over it as an unproven + * currency instead of fixed CNY. * * **Retired from the load path, from day one.** An authored `defaultCurrency` * on a `dynamic` config is legal and unread, so the authoring funnel does not @@ -13665,7 +13677,7 @@ const currencyConfigDynamicDefaultCurrencyRemoved: MetadataConversion = { summary: "currency field key 'currencyConfig.defaultCurrency' removed from a 'dynamic' config " + '(no reader takes a dynamic config\'s code — amounts display in the tenant default currency; ' - + 'the `\'CNY\'` an earlier parse baked in goes, and a `fixed` config keeps its code)', + + 'the `\'CNY\'` an earlier parse baked in goes; a `fixed` config is never touched)', apply(stack, emit) { const stripOn = (input: Dict, collection: string): Dict => mapCollection(input, collection, (owner, path) => { @@ -13697,8 +13709,8 @@ const currencyConfigDynamicDefaultCurrencyRemoved: MetadataConversion = { name: 'billing_ledger', label: 'Ledger', fields: { - // The baked default on a dynamic config — what a stored row carries - // without anyone having written it. + // The baked default on a dynamic config — what a built artifact + // carries without anyone having written it. tax: { type: 'currency', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, // No mode: parses to dynamic, so its code is unread too. fee: { type: 'currency', currencyConfig: { defaultCurrency: 'EUR' } }, diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 15dc7e6c014..efa71ced0e3 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -543,7 +543,7 @@ const CURRENCY_CONFIG_FIXED_WITHOUT_CODE = '`currencyConfig.currencyMode: \'fixed\'` needs `defaultCurrency`, the field\'s one currency: ' + 'give its ISO 4217 code (e.g. `defaultCurrency: \'USD\'`), or use `currencyMode: \'dynamic\'` ' + 'to display amounts in the tenant default currency (the `localization.currency` setting). ' - + 'Since @objectstack/spec 18 (ADR-0049) nothing fills a code in; it used to be `\'CNY\'`.'; + + 'Since @objectstack/spec 18 nothing fills a code in; it used to be `\'CNY\'`.'; /** `fixed` ⇒ an authored `defaultCurrency`; `dynamic` reads none, so it needs none. */ function refuseFixedCurrencyWithoutCode( @@ -580,8 +580,9 @@ function refuseFixedCurrencyWithoutCode( * `dynamic` one parses to output with no `defaultCurrency` unless one was * written. Stored rows and built artifacts that carry the baked `'CNY'` on a * `dynamic` config are stripped on rehydration by the ADR-0087 conversion - * `currency-config-dynamic-default-currency-removed`; a `fixed` row keeps its - * code, which was the live value. + * `currency-config-dynamic-default-currency-removed`. A `fixed` config is never + * converted: one with a code keeps it, and a stored row saved without one (rows + * are kept as authored) loads with diagnostics and is refused at its next save. */ export const CurrencyConfigSchema = lazySchema(() => strictObject({ surface: 'this currency configuration', diff --git a/packages/spec/src/migrations/entries/semantic/18.currency-config-fixed-default-currency-required.ts b/packages/spec/src/migrations/entries/semantic/18.currency-config-fixed-default-currency-required.ts index ee28443a614..8a71db9ac34 100644 --- a/packages/spec/src/migrations/entries/semantic/18.currency-config-fixed-default-currency-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.currency-config-fixed-default-currency-required.ts @@ -7,7 +7,9 @@ import type { SemanticMigration } from '../../types.js'; // is gone. The data-at-rest half is the D2 conversion // `currency-config-dynamic-default-currency-removed`, retired from the load // path from day one: stored rows and older built artifacts lose the code a -// dynamic config never read, and a fixed row keeps the code it displayed. +// dynamic config never read. A fixed config is never converted: one with a +// code keeps it, and a stored row saved without one stays as it is, loads +// with diagnostics, and is refused at its next save. export const entry: SemanticMigration = { id: 'currency-config-fixed-default-currency-required', // No backticks in `surface` — build-upgrade-guide renders it inside a code @@ -27,11 +29,17 @@ export const entry: SemanticMigration = { + 'parse output carried a CNY its readers had to know to ignore (a default the schema materialized ' + 'on every parse). The default is removed: a fixed config without a code is refused at every door ' + 'that parses a field, at the defaultCurrency path, with the prescription, and a dynamic config ' - + 'parses to output with no defaultCurrency unless one was written. STORED ROWS AND OLDER BUILT ' - + 'ARTIFACTS: every earlier parse baked the code in, so no stored fixed row lacks one; it keeps the ' - + 'code it displayed. The D2 conversion `currency-config-dynamic-default-currency-removed` strips ' - + 'the unread code from dynamic rows on rehydration and inside an artifact\'s declared-floor ' - + 'window, which changes no display. ADR-0049 / ADR-0087.', + + 'parses to output with no defaultCurrency unless one was written. OLDER BUILT ARTIFACTS hold the ' + + 'parsed stack, so a fixed config there carries the code the old parse filled in and keeps it. ' + + 'STORED sys_metadata OBJECT ROWS are kept as authored, so a row saved as { currencyMode: fixed } ' + + 'has no code, and nothing converts one in (a guessed CNY is what the ruling forbids). Such a row ' + + 'still loads and registers unchanged; boot counts it invalid with a [metadata_spec_invalid] line ' + + 'at fields.FIELD.currencyConfig.defaultCurrency, and its next save is refused with 422 ' + + 'INVALID_METADATA until the code is written. Its display does not change (the console reads a ' + + 'fixed code only when there is one, so it showed the tenant default before too); @objectstack/formula ' + + 'reads a formula over it as an unproven currency instead of fixed CNY. The D2 conversion ' + + '`currency-config-dynamic-default-currency-removed` strips the unread code from dynamic rows on ' + + 'rehydration and inside an artifact\'s declared-floor window, which changes no display. ADR-0087.', conversionIds: ['currency-config-dynamic-default-currency-removed'], acceptanceCriteria: 'Search every authored object, object extension and field definition for a `currencyConfig` with ' @@ -39,5 +47,7 @@ export const entry: SemanticMigration = { + 'displayed CNY until now), or switch it to dynamic. `FieldSchema.safeParse` answers one `custom` ' + 'issue at `currencyConfig.defaultCurrency` for each such field. The repository carried no fixed ' + 'config without a code at the time of the change (examples, platform objects, seeds and fixtures ' - + 'surveyed).', + + 'surveyed). For stored sys_metadata object rows, look for the `[metadata_spec_invalid]` boot line ' + + 'at `fields.FIELD.currencyConfig.defaultCurrency`: such a row displayed the tenant default, so ' + + 'switch it to dynamic or write the code it should carry, and save it.', }; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index a110d81cf5a..83d8602faf9 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1604,8 +1604,9 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'guesses). A `fixed` config without a code is refused with the prescription instead of ' + 'becoming a yuan field, and a `dynamic` config parses to output with no code. The D2 ' + 'conversion `currency-config-dynamic-default-currency-removed` strips the unread code an ' - + 'earlier parse baked into `dynamic` stored rows and built artifacts; a `fixed` row keeps ' - + 'its code. Retired from the load path.', + + 'earlier parse baked into `dynamic` stored rows and built artifacts; a `fixed` config is ' + + 'never converted, so a stored row saved without a code loads with diagnostics and is ' + + 'refused at its next save. Retired from the load path.', }, { id: 'currency-config-precision-retired', diff --git a/ui/packages/i18n/src/currency.ts b/ui/packages/i18n/src/currency.ts index 4db07c7bc3a..075de0ccad9 100644 --- a/ui/packages/i18n/src/currency.ts +++ b/ui/packages/i18n/src/currency.ts @@ -35,9 +35,12 @@ * @objectstack/spec dropped the `'CNY'` default (#27157), the spec parsed an * EMPTY `currencyConfig` to `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, * and rows stored or built by that parse still carry it until a rehydration - * strips it; it must not render as yuan. A `fixed` config now always carries - * its code (the spec refuses one without). If a value ever carries its own - * selected code, that code is the one to win, ahead of the tenant default. + * strips it; it must not render as yuan. The spec now refuses a `fixed` + * config without a code at authoring and save, but a stored row saved that + * way before is kept as authored and still loads with no code; step 2 skips + * it and the tenant default shows, as it always did. If a value ever carries + * its own selected code, that code is the one to win, ahead of the tenant + * default. * * This is the single resolution every field / measure / cell renderer shares - * ending the per-renderer drift where some read only `currency`, others only