From dc27f337b10823ad78a864e4af2a99f0109a242d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 18:22:41 +0000 Subject: [PATCH 1/2] feat(spec): reject a declared currency precision contradicting the currency's ISO 4217 fraction digits (#7918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling 2026-08-12, Option A. Two anchors, one shared verdict: the field-level precision key (no default — checked in FieldSchema's superRefine) and CurrencyConfigSchema.precision (default(2) relocated into a post-check .overwrite() so the superRefine sees authored-vs-absent; parse output stays byte-identical). Fires only when the currency is statically known (currencyConfig fixed mode); dynamic mode and non-CLDR codes fail open by design. Digit table is a checked-in CLDR 48.0 snapshot with provenance. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123k4cam2jEAkPmbJeoaY3r --- .changeset/currency-precision-iso4217.md | 37 +++ content/docs/data-modeling/field-types.mdx | 13 + .../spec/src/data/currency-fraction-digits.ts | 111 ++++++++ .../data/currency-precision-iso4217.test.ts | 238 ++++++++++++++++++ packages/spec/src/data/field.zod.ts | 87 ++++++- 5 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 .changeset/currency-precision-iso4217.md create mode 100644 packages/spec/src/data/currency-fraction-digits.ts create mode 100644 packages/spec/src/data/currency-precision-iso4217.test.ts diff --git a/.changeset/currency-precision-iso4217.md b/.changeset/currency-precision-iso4217.md new file mode 100644 index 0000000000..6a38323a24 --- /dev/null +++ b/.changeset/currency-precision-iso4217.md @@ -0,0 +1,37 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): reject a declared currency `precision` that contradicts the currency's ISO 4217 fraction digits (#7918) + +Maintainer ruling 2026-08-12 (Option A): when a currency field's currency is +statically known — `currencyConfig` in `fixed` mode — an **authored** +`precision` that contradicts that currency's ISO 4217 / CLDR fraction digits +is now a publish-time validation error, naming both numbers ("currency JPY has +0 fraction digits; `precision: 2` contradicts it"). `precision: 2` next to a +fixed JPY asked for two digits of a minor unit the yen does not have; +`precision: 2` on fixed KWD silently dropped the real third fils digit. The +check runs at both anchors the width can be authored on: the field-level +`precision` key and `currencyConfig.precision`. + +Deliberately partial, per the ruling: `dynamic` currencyMode has no single +currency to check against and is untouched by design; currency codes outside +CLDR `currencyData` (crypto/custom business codes) fail open — the code set +stays deliberately open. Only *authored* precision is judged: an untouched +`currencyConfig` (whose `precision` defaults to `2`) still parses byte-identically, +whatever its currency — the default was relocated from the property into a +post-check `.overwrite()` so the rule can see authored-vs-defaulted, with +parse output unchanged. + +This is an acceptance narrowing in the #3746 shape (minor, not major): every +newly-rejected input was a contradiction no consumer could honor — renderers +already derive the width from the currency when precision is absent +(objectui#4361), and the rejected combinations rendered money with digits the +currency does not have. Fix a rejected field by dropping `precision` (derive +from the currency) or setting it to the currency's own digit count, which the +error message states. + +One visible type-level change: `CurrencyConfigParsed.precision` is now +inferred `number | undefined` (the #6926 `.overwrite()` cost) although a +parsed config always carries a number at runtime — the materialized default +is unchanged. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 8b8acb1d29..2a9438337b 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -161,6 +161,19 @@ Monetary value with currency configuration. { name: 'price', label: 'Price', type: 'currency', currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' } } ``` +A declared `precision` must agree with the currency's ISO 4217 fraction digits +when the currency is statically known — that is, when `currencyConfig` is in +`fixed` mode. Publish-time validation rejects the contradiction, naming both +numbers ("currency JPY has 0 fraction digits; `precision: 2` contradicts it"): +the yen has no minor unit to show two digits of, and `precision: 2` on a KWD +field would silently drop the third fils digit that exists. Omit `precision` +to let renderers derive the width from the currency itself. The rule is +deliberately partial: a field in `dynamic` currencyMode has no single currency +to check against, and currency codes outside CLDR `currencyData` +(cryptocurrency or custom business codes) are not checked. The rule judges +only *authored* precision — an untouched `currencyConfig` (whose `precision` +defaults to `2`) is never rejected, whatever its currency. + ### `percent` Percentage value (0–100 or decimal). diff --git a/packages/spec/src/data/currency-fraction-digits.ts b/packages/spec/src/data/currency-fraction-digits.ts new file mode 100644 index 0000000000..ad65dd9d6f --- /dev/null +++ b/packages/spec/src/data/currency-fraction-digits.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ISO 4217 / CLDR currency fraction digits — the static digit table behind the + * #7918 publish-time rule (maintainer ruling 2026-08-12, Option A): a currency + * field whose AUTHORED `precision` contradicts its statically-known currency's + * fraction digits is rejected at parse/publish time, with a message naming + * both numbers. + * + * ## Provenance — CLDR `currencyData`, checked in, not probed at runtime + * + * Generated from CLDR 48.0 `currencyData` digit counts as carried by ICU 78.2 + * (node v22.22.2 full-icu, Unicode 17.0), on 2026-08-12, via: + * + * ```js + * for (const c of Intl.supportedValuesOf('currency')) + * table[c] = new Intl.NumberFormat(undefined, { style: 'currency', currency: c }) + * .resolvedOptions().maximumFractionDigits; + * ``` + * + * The probe is locale-free on purpose: the digit count comes from CLDR's + * `currencyData`, keyed by the currency and not by the reader — measured + * identical across en-US / de-DE / ja-JP / ar-KW / zh-CN / fr-FR / pl-PL / + * es-ES (the #7918 card's own measurement, and objectui's + * `currencyFractionDigits` renderer helper carries the same one). Checking a + * CHECKED-IN snapshot rather than asking `Intl` at validation time keeps + * publish-time validation deterministic — the verdict cannot vary with the + * host's ICU build (a small-icu node answers 2 for everything), and the + * validation path takes no `Intl` dependency at all. + * + * Renderers still derive display width from live `Intl` (objectui#4361), and + * the two sources agree because both read CLDR `currencyData`. If a future + * CLDR revision moves a digit count, regenerate with the snippet above and + * update the provenance line — the table is a snapshot, not hand-curated data. + * + * ## The set is deliberately OPEN — unknown codes fail OPEN + * + * `CurrencyConfigSchema` validates currency codes by length only, on purpose: + * cryptocurrency and custom business codes (BTC, ETH, …) are legal. A code + * this table does not know gets `undefined` — NO verdict — so the #7918 rule + * does not fire on it. Refusing unknown codes would be a different rule that + * nobody ruled; do not "improve" this into a membership check. + */ + +/** + * Fraction digits per currency code — every code CLDR 48.0 `currencyData` + * carries (162 entries; see the provenance block above). 0-digit currencies + * (JPY, KRW, CLP, ISK, VND, …) have no minor unit at all; 3-digit currencies + * (BHD, JOD, KWD, LYD, OMR, TND) have a thousandth minor unit (fils/baisa). + */ +export const CURRENCY_FRACTION_DIGITS: Readonly> = { + AED: 2, AFN: 0, ALL: 0, AMD: 2, ANG: 2, AOA: 2, ARS: 2, AUD: 2, + AWG: 2, AZN: 2, BAM: 2, BBD: 2, BDT: 2, BGN: 2, BHD: 3, BIF: 0, + BMD: 2, BND: 2, BOB: 2, BRL: 2, BSD: 2, BTN: 2, BWP: 2, BYN: 2, + BZD: 2, CAD: 2, CDF: 2, CHF: 2, CLP: 0, CNY: 2, COP: 0, CRC: 2, + CUC: 2, CUP: 2, CVE: 2, CZK: 2, DJF: 0, DKK: 2, DOP: 2, DZD: 2, + EGP: 2, ERN: 2, ETB: 2, EUR: 2, FJD: 2, FKP: 2, GBP: 2, GEL: 2, + GHS: 2, GIP: 2, GMD: 2, GNF: 0, GTQ: 2, GYD: 2, HKD: 2, HNL: 2, + HRK: 2, HTG: 2, HUF: 0, IDR: 0, ILS: 2, INR: 2, IQD: 0, IRR: 0, + ISK: 0, JMD: 2, JOD: 3, JPY: 0, KES: 2, KGS: 2, KHR: 2, KMF: 0, + KPW: 0, KRW: 0, KWD: 3, KYD: 2, KZT: 2, LAK: 0, LBP: 0, LKR: 2, + LRD: 2, LSL: 2, LYD: 3, MAD: 2, MDL: 2, MGA: 0, MKD: 2, MMK: 0, + MNT: 2, MOP: 2, MRU: 2, MUR: 2, MVR: 2, MWK: 2, MXN: 2, MYR: 2, + MZN: 2, NAD: 2, NGN: 2, NIO: 2, NOK: 2, NPR: 2, NZD: 2, OMR: 3, + PAB: 2, PEN: 2, PGK: 2, PHP: 2, PKR: 0, PLN: 2, PYG: 0, QAR: 2, + RON: 2, RSD: 2, RUB: 2, RWF: 0, SAR: 2, SBD: 2, SCR: 2, SDG: 2, + SEK: 2, SGD: 2, SHP: 2, SLE: 2, SLL: 0, SOS: 0, SRD: 2, SSP: 2, + STN: 2, SVC: 2, SYP: 0, SZL: 2, THB: 2, TJS: 2, TMT: 2, TND: 3, + TOP: 2, TRY: 2, TTD: 2, TWD: 2, TZS: 2, UAH: 2, UGX: 0, USD: 2, + UYU: 2, UZS: 2, VES: 2, VND: 0, VUV: 0, WST: 2, XAF: 0, XCD: 2, + XCG: 2, XDR: 2, XOF: 0, XPF: 0, XSU: 2, YER: 0, ZAR: 2, ZMW: 2, + ZWG: 2, ZWL: 2, +}; + +/** + * The fraction digits CLDR gives `code`, or `undefined` for a code outside + * the table (crypto/custom — the open-set, fail-open case above). Case-folded + * to match `Intl.NumberFormat`'s own case-insensitive currency handling, so a + * lowercased `'jpy'` — legal under the length-3 schema — cannot dodge the + * check that `'JPY'` gets. + */ +export function currencyFractionDigits(code: string): number | undefined { + return CURRENCY_FRACTION_DIGITS[code.toUpperCase()]; +} + +/** + * The #7918 verdict, shared by BOTH anchors of the rule — the field-level + * `precision` key (checked in `FieldSchema`'s `superRefine`) and + * `CurrencyConfigSchema.precision` (checked pre-default inside that schema) — + * so the two doors cannot drift apart in wording. Returns the issue message + * when `precision` contradicts `currency`'s fraction digits, `undefined` when + * they agree or the currency is unknown (fail-open). + * + * The message's first clause names both numbers, verbatim per the ruling: + * "currency JPY has 0 fraction digits; `precision: 2` contradicts it". + */ +export function currencyPrecisionContradiction( + currency: string, + precision: number, +): string | undefined { + const digits = currencyFractionDigits(currency); + if (digits === undefined || digits === precision) return undefined; + return ( + `currency ${currency.toUpperCase()} has ${digits} fraction digits; ` + + `\`precision: ${precision}\` contradicts it — the amount would render minor-unit digits ` + + `the currency does not have, or drop digits it does (ISO 4217 / CLDR currencyData). ` + + `Declare \`precision: ${digits}\`, or omit \`precision\` and let renderers derive the width ` + + `from the currency. Codes outside CLDR (crypto/custom) are not checked, and a field in ` + + `\`dynamic\` currencyMode has no single currency to check against.` + ); +} diff --git a/packages/spec/src/data/currency-precision-iso4217.test.ts b/packages/spec/src/data/currency-precision-iso4217.test.ts new file mode 100644 index 0000000000..96ac697389 --- /dev/null +++ b/packages/spec/src/data/currency-precision-iso4217.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7918 (maintainer ruling 2026-08-12, Option A) — publish-time rejection of a + * declared currency `precision` that contradicts the currency's ISO 4217 / + * CLDR fraction digits, when the currency is statically known + * (`currencyConfig.currencyMode: 'fixed'`). + * + * The rule is deliberately partial: `dynamic` currencyMode has no single + * currency to check against and is out of reach BY DESIGN; codes outside CLDR + * `currencyData` (crypto/custom) fail OPEN. The rule fires ONLY on an AUTHORED + * `precision` — the materialized `.default(2)` on an untouched fixed-JPY + * config must never fire (the permanently-noisy shape the ruling forbids), + * which is what the pre-default anchoring inside `CurrencyConfigSchema` (its + * `.superRefine` before the `.overwrite` that materializes the default) + * exists to deliver. + * + * Key-vs-value note: these are VALUE verdicts (a declared width judged against + * the currency), so the assertions demand full `safeParse` outcomes — not mere + * key reachability. + */ + +import { describe, expect, it } from 'vitest'; +import { CurrencyConfigSchema, FieldSchema } from './field.zod'; +import { + CURRENCY_FRACTION_DIGITS, + currencyFractionDigits, + currencyPrecisionContradiction, +} from './currency-fraction-digits'; + +/** The one custom-issue the rule emits, or undefined when the parse passed. */ +function firstIssue(result: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }) { + return result.success ? undefined : result.error!.issues[0]; +} + +describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyConfigSchema)', () => { + it('rejects an authored precision contradicting a 0-digit currency (JPY + 2)', () => { + const result = CurrencyConfigSchema.safeParse({ + precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY', + }); + expect(result.success).toBe(false); + const issue = firstIssue(result)!; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['precision']); + // The ruling's message shape: both numbers, named. + expect(issue.message).toContain('currency JPY has 0 fraction digits'); + expect(issue.message).toContain('`precision: 2` contradicts it'); + }); + + it('rejects an authored precision contradicting a 3-digit currency (KWD + 2)', () => { + const result = CurrencyConfigSchema.safeParse({ + precision: 2, currencyMode: 'fixed', defaultCurrency: 'KWD', + }); + expect(result.success).toBe(false); + expect(firstIssue(result)!.message).toContain('currency KWD has 3 fraction digits'); + expect(firstIssue(result)!.message).toContain('`precision: 2` contradicts it'); + }); + + it('THE noisy-shape guard: an untouched fixed-JPY config (defaulted precision) parses clean', () => { + // The baked default 2 "contradicts" JPY's 0 digits — but it was never + // authored, so the rule must not fire. This is the assertion that proves + // the pre-default anchoring; with a property-level `.default(2)` it goes + // red (measured in this card's reverse verification). + const result = CurrencyConfigSchema.safeParse({ + currencyMode: 'fixed', defaultCurrency: 'JPY', + }); + expect(result.success).toBe(true); + expect(result.data!.precision).toBe(2); + }); + + it('dynamic currencyMode is out of reach by design (JPY + 2 + dynamic passes)', () => { + const result = CurrencyConfigSchema.safeParse({ + precision: 2, currencyMode: 'dynamic', defaultCurrency: 'JPY', + }); + expect(result.success).toBe(true); + }); + + it('defaulted currencyMode (dynamic) is equally out of reach', () => { + expect(CurrencyConfigSchema.safeParse({ precision: 2, defaultCurrency: 'JPY' }).success).toBe(true); + }); + + it('unknown codes fail open (fixed BTC + 8 passes — the open-set contract)', () => { + const result = CurrencyConfigSchema.safeParse({ + precision: 8, currencyMode: 'fixed', defaultCurrency: 'BTC', + }); + expect(result.success).toBe(true); + expect(result.data!.precision).toBe(8); + }); + + it('authored precision in fixed mode is judged against the DEFAULTED currency too (CNY + 0)', () => { + // `currencyMode: 'fixed'` with no code pins the schema default CNY as the + // field's one currency; an authored `precision: 0` contradicts its 2. + // The precision was authored, so this is not the noisy shape. + const result = CurrencyConfigSchema.safeParse({ currencyMode: 'fixed', precision: 0 }); + expect(result.success).toBe(false); + expect(firstIssue(result)!.message).toContain('currency CNY has 2 fraction digits'); + expect(firstIssue(result)!.message).toContain('`precision: 0` contradicts it'); + }); + + it('a lowercased code cannot dodge the check (Intl-style case folding)', () => { + const result = CurrencyConfigSchema.safeParse({ + precision: 2, currencyMode: 'fixed', defaultCurrency: 'jpy', + }); + expect(result.success).toBe(false); + expect(firstIssue(result)!.message).toContain('currency JPY has 0 fraction digits'); + }); + + it('agreeing combos parse byte-identically to the `.default(2)` era', () => { + // Measured on origin/main (37b82ed5b) before this change — same shape + // order, same materialized default, byte for byte. + const cases: Array<[Record, string]> = [ + [{ precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, + '{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'], + [{ precision: 0, currencyMode: 'fixed', defaultCurrency: 'JPY' }, + '{"precision":0,"currencyMode":"fixed","defaultCurrency":"JPY"}'], + [{ precision: 3, currencyMode: 'fixed', defaultCurrency: 'KWD' }, + '{"precision":3,"currencyMode":"fixed","defaultCurrency":"KWD"}'], + [{ currencyMode: 'fixed', defaultCurrency: 'JPY' }, + '{"precision":2,"currencyMode":"fixed","defaultCurrency":"JPY"}'], + [{}, '{"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}'], + ]; + for (const [input, expected] of cases) { + expect(JSON.stringify(CurrencyConfigSchema.parse(input))).toBe(expected); + } + }); + + it('the `decimals`/`scale` alias spellings funnel into the canonical key (strict rejection + suggestion)', () => { + // `strictObject` aliases are rejection-with-suggestion, not renames: an + // alias spelling cannot silently carry a contradicting width past the + // check — the author is pointed at `precision`, where the check waits. + for (const alias of ['decimals', 'scale'] as const) { + const result = CurrencyConfigSchema.safeParse({ + currencyMode: 'fixed', defaultCurrency: 'JPY', [alias]: 2, + }); + expect(result.success).toBe(false); + const messages = result.error!.issues.map((i) => i.message).join('\n'); + expect(messages).toContain(alias); + expect(messages).toContain('precision'); + } + }); +}); + +describe('#7918 — field-level anchor (FieldSchema.superRefine; the key has no default)', () => { + const base = { name: 'amount', label: 'Amount', type: 'currency' as const }; + + it('rejects an authored field-level precision contradicting the fixed currency (JPY + 2)', () => { + const result = FieldSchema.safeParse({ + ...base, precision: 2, + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }, + }); + expect(result.success).toBe(false); + const issue = firstIssue(result)!; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['precision']); + expect(issue.message).toContain('currency JPY has 0 fraction digits'); + expect(issue.message).toContain('`precision: 2` contradicts it'); + }); + + it('accepts a field-level precision agreeing with the fixed currency (JPY + 0, KWD + 3)', () => { + expect(FieldSchema.safeParse({ + ...base, precision: 0, + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }, + }).success).toBe(true); + expect(FieldSchema.safeParse({ + ...base, precision: 3, + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'KWD' }, + }).success).toBe(true); + }); + + it('no currencyConfig ⇒ no statically-known currency ⇒ no verdict', () => { + expect(FieldSchema.safeParse({ ...base, precision: 2 }).success).toBe(true); + }); + + it('dynamic (authored or defaulted) currencyMode ⇒ no verdict', () => { + expect(FieldSchema.safeParse({ + ...base, precision: 2, + currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'JPY' }, + }).success).toBe(true); + expect(FieldSchema.safeParse({ + ...base, precision: 2, + currencyConfig: { defaultCurrency: 'JPY' }, + }).success).toBe(true); + }); + + it('non-currency fields keep `precision` as the number vocabulary (total digits) — untouched', () => { + expect(FieldSchema.safeParse({ + name: 'total', label: 'Total', type: 'number' as const, precision: 2, + }).success).toBe(true); + }); + + it('the currencyConfig-level check reaches through the FieldSchema door (path is prefixed)', () => { + const result = FieldSchema.safeParse({ + ...base, + currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' }, + }); + expect(result.success).toBe(false); + const issue = firstIssue(result)!; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['currencyConfig', 'precision']); + expect(issue.message).toContain('currency JPY has 0 fraction digits'); + }); + + it('both anchors fire independently when both keys contradict', () => { + const result = FieldSchema.safeParse({ + ...base, precision: 2, + currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' }, + }); + expect(result.success).toBe(false); + const paths = result.error!.issues.map((i) => i.path.join('.')); + expect(paths).toContain('precision'); + expect(paths).toContain('currencyConfig.precision'); + }); +}); + +describe('#7918 — the digit table itself', () => { + it("carries the card's measured anchors", () => { + // 0: JPY/KRW/CLP/ISK/VND — 2: USD/EUR/CNY/GBP — 3: KWD/BHD/OMR/TND + for (const c of ['JPY', 'KRW', 'CLP', 'ISK', 'VND']) expect(currencyFractionDigits(c)).toBe(0); + for (const c of ['USD', 'EUR', 'CNY', 'GBP']) expect(currencyFractionDigits(c)).toBe(2); + for (const c of ['KWD', 'BHD', 'OMR', 'TND']) expect(currencyFractionDigits(c)).toBe(3); + }); + + it('answers undefined for codes outside CLDR (the fail-open contract)', () => { + for (const c of ['BTC', 'ETH', 'ZZZ']) expect(currencyFractionDigits(c)).toBeUndefined(); + }); + + it('is a full CLDR snapshot, not a hand-typed subset', () => { + // CLDR 48.0 currencyData carries 162 codes (see the module's provenance + // block). A shrunk table silently widens the fail-open surface. + expect(Object.keys(CURRENCY_FRACTION_DIGITS).length).toBe(162); + }); + + it('the shared verdict names both numbers and stays silent on agreement/unknown', () => { + expect(currencyPrecisionContradiction('JPY', 2)).toContain('currency JPY has 0 fraction digits'); + expect(currencyPrecisionContradiction('JPY', 0)).toBeUndefined(); + expect(currencyPrecisionContradiction('BTC', 8)).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 12310feb78..4674e941cf 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -22,6 +22,10 @@ import { suggestDefaultValueToken, } from './default-value-shape'; import { AddressSchema } from './field-value.zod'; +// #7918 — the ISO 4217 / CLDR fraction-digit contradiction check (maintainer +// ruling 2026-08-12, Option A). One shared verdict for both anchors: the +// field-level `precision` key and `CurrencyConfigSchema.precision`. +import { currencyPrecisionContradiction } from './currency-fraction-digits'; /** * Field Type Enum @@ -208,10 +212,63 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({ history: FIELD_HISTORY, aliases: { decimals: 'precision', scale: 'precision', mode: 'currencyMode', currency: 'defaultCurrency', code: 'defaultCurrency', isoCode: 'defaultCurrency' }, }, { - precision: z.number().int().min(0).max(10).default(2).describe('Decimal precision (default: 2)'), + /** + * #7918 — `.default(2)` moved off this property and into the `.overwrite()` + * below, and this placement is load-bearing. A property-level default + * materializes AT PARSE, so a refinement over the parsed object cannot tell + * an authored `precision: 2` from an untouched one — and a rule firing on + * the baked default would refuse every untouched JPY currencyConfig (the + * permanently-noisy shape the ruling forbids). Declared `.optional()`, the + * authored-vs-absent distinction survives to the `.superRefine` below; + * the `.overwrite` then materializes the same `2` AFTER the check, so parse + * OUTPUT is byte-identical to the `.default(2)` era. The `default: 2` + * annotation states the contract default to schema consumers without + * touching parse order — the `autonumberFormat` pattern below. + */ + precision: z.number().int().min(0).max(10).optional().meta({ + description: 'Decimal precision (default: 2)', + default: 2, + }), currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode: dynamic (user selectable) or fixed (single currency)'), defaultCurrency: z.string().length(3).default('CNY').describe('Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)'), -})); +}).superRefine((config, ctx) => { + // #7918 (maintainer ruling 2026-08-12, Option A): an AUTHORED `precision` + // that contradicts the statically-known currency's ISO 4217 / CLDR fraction + // digits is a publish-time error — `precision: 2` on a fixed-JPY config asks + // for two digits of a minor unit the yen does not have; `precision: 2` on + // fixed-KWD silently drops the third fils digit that exists. + // + // Deliberately partial, per the ruling: only `currencyMode: 'fixed'` pins a + // single currency to check against — `dynamic` mode is out of reach BY + // DESIGN (do not "improve" it), and codes outside CLDR `currencyData` + // (crypto/custom) fail OPEN. `config.precision` here is pre-`.overwrite`, + // so `undefined` means "not authored" — the defaulted 2 on an untouched + // fixed-JPY config never fires. `defaultCurrency` and `currencyMode` keep + // their property defaults: in authored-`fixed` mode the (possibly defaulted) + // `defaultCurrency` IS the field's one currency, so an authored `precision` + // contradicting it is judged even when the code itself was defaulted. + if (config.precision === undefined || config.currencyMode !== 'fixed') return; + const contradiction = currencyPrecisionContradiction(config.defaultCurrency, config.precision); + if (contradiction !== undefined) { + ctx.addIssue({ code: 'custom', path: ['precision'], message: contradiction }); + } +}).overwrite((config) => ({ + // #7918 — the relocated `.default(2)`, applied AFTER the check above. + // `.overwrite()` rather than `.transform()` per the measured #6926 precedent + // (view.zod.ts `foldFormGroupsIntoSections`): it keeps this schema a + // `ZodObject` (a pipe has no `.extend` and answers shape introspection with + // an empty set), and checks run in attachment order, so the superRefine + // above always sees the pre-materialized value. Rebuilt in shape order so + // the output is byte-identical to the `.default(2)` era: + // `{precision, currencyMode, defaultCurrency}`, `precision` always a number. + // The one accepted cost, same as #6926's: the INFERRED output type still + // declares `precision?` even though a parsed config always carries it + // (ADR-0122 forbids hand-narrowing `CurrencyConfigParsed`); the runtime + // contract is the enforced one. + precision: config.precision ?? 2, + currencyMode: config.currencyMode, + defaultCurrency: config.defaultCurrency, +}))); /** * Currency Value Schema @@ -1035,6 +1092,32 @@ export const FieldSchema = lazySchema(() => strictObject({ }); } + // #7918 (maintainer ruling 2026-08-12, Option A): the FIELD-level + // `precision` key doubles as the currency display width — objectui's + // CurrencyField reads it, and objectui#4361 pinned authored-precision-wins + // there — so an authored value contradicting the statically-known currency's + // ISO 4217 / CLDR fraction digits is rejected at this seam too. The currency + // is statically known only under `currencyConfig.currencyMode: 'fixed'` + // (`dynamic` is out of reach BY DESIGN; a field with no `currencyConfig` has + // only the runtime tenant default, which is not static). This key has NO + // schema default, so `undefined` here IS "not authored" — the + // authored-vs-defaulted trap lives entirely on the `currencyConfig` twin of + // this check, which runs pre-default inside `CurrencyConfigSchema` itself. + // Unknown currency codes fail OPEN (see currency-fraction-digits.ts). + if ( + field.type === 'currency' && + field.precision !== undefined && + field.currencyConfig?.currencyMode === 'fixed' + ) { + const contradiction = currencyPrecisionContradiction( + field.currencyConfig.defaultCurrency, + field.precision, + ); + if (contradiction !== undefined) { + ctx.addIssue({ code: 'custom', path: ['precision'], message: contradiction }); + } + } + // #7127: an authored `defaultValue` must be one of the key's three legal // shapes — CEL envelope / runtime token / literal — and legal for THIS // field. The shapes are told apart FIRST (`default-value-shape.ts`, the From 0ca791e3e7e2ff85745764a8f4e3474e3a6a73af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 18:56:22 +0000 Subject: [PATCH 2/2] docs(spec): regenerate references for the CurrencyConfigSchema precision optionality (#7918) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123k4cam2jEAkPmbJeoaY3r --- content/docs/references/data/field.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index c31ad9f95f..9fb5771cad 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 | | :--- | :--- | :--- | :--- | -| **precision** | `integer` | ✅ | Decimal precision (default: 2) | +| **precision** | `integer` | optional | Decimal precision (default: 2) | | **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | ✅ | Currency mode: dynamic (user selectable) or fixed (single currency) | | **defaultCurrency** | `string` | ✅ | Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR) |