From f96b1c3ec7705bbbed696308e06c81016d6eee89 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 15:30:18 +0000 Subject: [PATCH 1/2] fix(spec): never materialize the currency precision default onto a fixed currency whose fraction digits refuse it (#11423) The CurrencyConfigSchema .overwrite() baked precision 2 onto a bare fixed-JPY/KRW/KWD-class config; the superRefine refuses that spelling as authored, and the two are indistinguishable by design - so parse output rejected itself on the mainline ObjectSchema.create() -> defineStack re-parse. One conditional in the .overwrite() (the #9689 master_detail precedent one screen away): the refused combination parses to output that OMITS precision; every other combination keeps byte-identity. parse(parse(x)) pinned idempotent at CurrencyConfigSchema, FieldSchema and the create() -> defineStack chain; authored contradictions stay rejected with the same message; bare fixed-USD still materializes 2 byte-identically. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- .../data/currency-precision-iso4217.test.ts | 96 +++++++++++++++++-- packages/spec/src/data/field.zod.ts | 49 ++++++++-- 2 files changed, 128 insertions(+), 17 deletions(-) diff --git a/packages/spec/src/data/currency-precision-iso4217.test.ts b/packages/spec/src/data/currency-precision-iso4217.test.ts index 96ac697389..b06763e079 100644 --- a/packages/spec/src/data/currency-precision-iso4217.test.ts +++ b/packages/spec/src/data/currency-precision-iso4217.test.ts @@ -22,6 +22,7 @@ import { describe, expect, it } from 'vitest'; import { CurrencyConfigSchema, FieldSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; import { CURRENCY_FRACTION_DIGITS, currencyFractionDigits, @@ -57,15 +58,18 @@ describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyCon }); 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). + // The 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). Since #11423 the default + // is also no longer MATERIALIZED on this combination (the schema would + // refuse it as authored — see the idempotency block below), so the parsed + // output omits `precision` rather than carrying 2. const result = CurrencyConfigSchema.safeParse({ currencyMode: 'fixed', defaultCurrency: 'JPY', }); expect(result.success).toBe(true); - expect(result.data!.precision).toBe(2); + expect(result.data!.precision).toBeUndefined(); }); it('dynamic currencyMode is out of reach by design (JPY + 2 + dynamic passes)', () => { @@ -107,7 +111,11 @@ describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyCon 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. + // order, same materialized default, byte for byte. The one #11423 flip is + // deliberately NOT in this battery: a bare fixed-JPY config now omits + // `precision` (the schema would refuse the materialized 2 as authored — + // pinned in the idempotency block below); every combination here either + // authored its precision or cannot be refused, so byte-identity holds. const cases: Array<[Record, string]> = [ [{ precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, '{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'], @@ -115,8 +123,10 @@ describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyCon '{"precision":0,"currencyMode":"fixed","defaultCurrency":"JPY"}'], [{ precision: 3, currencyMode: 'fixed', defaultCurrency: 'KWD' }, '{"precision":3,"currencyMode":"fixed","defaultCurrency":"KWD"}'], + [{ currencyMode: 'fixed', defaultCurrency: 'USD' }, + '{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'], [{ currencyMode: 'fixed', defaultCurrency: 'JPY' }, - '{"precision":2,"currencyMode":"fixed","defaultCurrency":"JPY"}'], + '{"currencyMode":"fixed","defaultCurrency":"JPY"}'], [{}, '{"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}'], ]; for (const [input, expected] of cases) { @@ -140,6 +150,78 @@ describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyCon }); }); +// [#11423] (maintainer ruling routed from #9689, 2026-08-24, idempotent +// materialization): the `.overwrite()` never materializes a default the schema +// itself would refuse as authored. Baking `precision: 2` onto a bare fixed +// zero-/three-digit-currency config (JPY/KRW/KWD class) made parse output +// self-rejecting on re-parse — `parse(parse(x))` threw for accepted x, and the +// re-parse chain is the mainline authoring path (`ObjectSchema.create()` +// returns parse output; `objectstack build`'s defineStack parses it again). +// Same one-conditional shape as the #9689 master_detail guard in field.zod.ts. +describe('#11423 — the materialized precision default is never one the schema itself refuses', () => { + it('a bare fixed-JPY config parses green and OMITS precision — parse(parse(x)) is idempotent', () => { + // The card's measured break: parse #1 baked `precision: 2`, parse #2 + // rejected it at `currencyConfig.precision` ("currency JPY has 0 fraction + // digits; `precision: 2` contradicts it"). Absent is the honest spelling. + const once = CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'JPY' }); + expect(once.precision).toBeUndefined(); + expect('precision' in once).toBe(false); + const again = CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once))); + expect(again.success).toBe(true); + expect(JSON.stringify(again.data)).toBe(JSON.stringify(once)); + }); + + it('parse is IDEMPOTENT through the mainline create() → defineStack chain (the chain that carried the defect)', () => { + const field = FieldSchema.parse({ + name: 'amount', label: 'Amount', type: 'currency', + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }, + }); + expect(FieldSchema.safeParse(JSON.parse(JSON.stringify(field))).success).toBe(true); + const obj = ObjectSchema.create({ + name: 'invoice', label: 'Invoice', + fields: { amount: { label: 'Amount', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } } }, + }); + expect(ObjectSchema.safeParse(obj).success).toBe(true); + }); + + it('an AUTHORED contradictory precision is still rejected with the named message (the guard narrows materialization, not the rule)', () => { + 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']); + expect(issue.message).toContain('currency JPY has 0 fraction digits'); + expect(issue.message).toContain('`precision: 2` contradicts it'); + }); + + it('a bare fixed-USD config still materializes precision 2 byte-identically (the default keeps baking where it is legal — #7918 relocation intact)', () => { + expect(JSON.stringify(CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'USD' }))) + .toBe('{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'); + }); + + it('the whole refused class skips materialization — 0-digit (KRW) and 3-digit (KWD) fixed currencies omit precision and re-parse green', () => { + for (const code of ['KRW', 'KWD']) { + const once = CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: code }); + expect('precision' in once).toBe(false); + expect(CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once))).success).toBe(true); + } + }); + + it('combinations the superRefine cannot refuse keep materializing — dynamic mode and unknown fixed codes', () => { + // dynamic + JPY: no single currency to check against, baked 2 re-parses + // green (the superRefine only judges `fixed`); unknown fixed code: the + // digit table fails OPEN, so 2 is never refused. + expect(CurrencyConfigSchema.parse({ defaultCurrency: 'JPY' }).precision).toBe(2); + expect(CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'BTC' }).precision).toBe(2); + for (const input of [{ defaultCurrency: 'JPY' }, { currencyMode: 'fixed', defaultCurrency: 'BTC' }]) { + const once = CurrencyConfigSchema.parse(input); + expect(CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once))).success).toBe(true); + } + }); +}); + describe('#7918 — field-level anchor (FieldSchema.superRefine; the key has no default)', () => { const base = { name: 'amount', label: 'Amount', type: 'currency' as const }; diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 505149e183..4bfe78eb37 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -288,7 +288,7 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({ if (contradiction !== undefined) { ctx.addIssue({ code: 'custom', path: ['precision'], message: contradiction }); } -}).overwrite((config) => ({ +}).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 @@ -296,15 +296,44 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({ // 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, -}))); + // `{precision, currencyMode, defaultCurrency}`, `precision` always a number + // — except on the guarded combination below. The one accepted cost, same as + // #6926's: the INFERRED output type still declares `precision?` even though + // a parsed config normally carries it (ADR-0122 forbids hand-narrowing + // `CurrencyConfigParsed`); the runtime contract is the enforced one. + // + // #11423 (maintainer ruling on #9689, 2026-08-24, routed to this twin — + // 「The same principle prescribes the fix for the #7918 currency twin + // (#11423) — the spec seat should route it under this ruling.」): NEVER + // materialize a default the schema itself would refuse as authored. The + // superRefine above rejects an AUTHORED `precision: 2` on a fixed + // zero-/three-fraction-digit currency (JPY/KRW/KWD class), and the two + // spellings are indistinguishable to any later parse BY DESIGN — so baking + // `2` onto a bare fixed-JPY config made parse output self-rejecting on + // re-parse, and `ObjectSchema.create()` → `defineStack` re-parses on the + // MAINLINE app-build path (measured: `parse(parse(x))` threw at + // `currencyConfig.precision` for accepted x). A bare fixed config whose + // currency contradicts the default 2 therefore parses to output that OMITS + // `precision`: renderers already derive display width from the currency + // when the key is absent, and built artifacts stop carrying a value the + // schema itself refuses. Every other combination keeps byte-identity — + // `dynamic` mode and unknown codes (fail-open table) can never be refused, + // so they keep materializing. The #9689 master_detail `deleteBehavior` + // conditional in `FieldSchema`'s `.overwrite()` below is the worked + // precedent; #11423 is its recorded currency twin. + if ( + config.precision === undefined && + config.currencyMode === 'fixed' && + currencyPrecisionContradiction(config.defaultCurrency, 2) !== undefined + ) { + return config; + } + return { + precision: config.precision ?? 2, + currencyMode: config.currencyMode, + defaultCurrency: config.defaultCurrency, + }; +})); /** * Currency Value Schema From c3613e13715d3a71d27000481e24196bd30915ab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 15:46:03 +0000 Subject: [PATCH 2/2] chore: changeset for the #11423 currency precision idempotency fix Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- .changeset/thin-yen-keeps-no-cents.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/thin-yen-keeps-no-cents.md diff --git a/.changeset/thin-yen-keeps-no-cents.md b/.changeset/thin-yen-keeps-no-cents.md new file mode 100644 index 0000000000..8b01f0042f --- /dev/null +++ b/.changeset/thin-yen-keeps-no-cents.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': minor +--- + +`CurrencyConfigSchema` no longer materializes the `precision` default onto a configuration the schema itself would refuse as authored (#11423). A bare `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }` used to parse to `precision: 2` — and the #7918 rule rejects an authored `precision: 2` against JPY's 0 fraction digits, with the materialized and authored spellings indistinguishable by design — so parse output rejected itself on the mainline `ObjectSchema.create()` → `defineStack` re-parse: `parse(parse(x))` threw for an input `parse(x)` accepts. + +Mechanism (the #9689 idempotent-materialization ruling, applied to its recorded currency twin): one conditional in the `.overwrite()` — when `currencyMode` is `fixed`, no `precision` was authored, and the currency's ISO 4217 / CLDR fraction digits contradict the default `2` (the JPY/KRW/KWD class), the parsed output OMITS `precision` instead of baking a value the schema refuses. Renderers already derive display width from the currency when the key is absent (objectui#4361), so absent is the honest spelling. Every other combination keeps byte-identical output: an authored `precision` is untouched, a bare fixed 2-fraction-digit config (USD/EUR/CNY…) still materializes `precision: 2` at its shape position, and `dynamic` mode and non-CLDR codes (crypto/custom, fail-open) keep materializing — none of those can be refused. The #7918 rejection of an authored contradictory `precision` is unchanged, message and path included.