Skip to content

Commit c4db311

Browse files
os-warrenclaude
andauthored
fix(spec): never materialize the currency precision default the schema itself refuses — bare fixed-JPY parse is idempotent (#11423) (#11766)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy * chore: changeset for the #11423 currency precision idempotency fix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 772d5de commit c4db311

3 files changed

Lines changed: 135 additions & 17 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`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.
6+
7+
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.

packages/spec/src/data/currency-precision-iso4217.test.ts

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import { describe, expect, it } from 'vitest';
2424
import { CurrencyConfigSchema, FieldSchema } from './field.zod';
25+
import { ObjectSchema } from './object.zod';
2526
import {
2627
CURRENCY_FRACTION_DIGITS,
2728
currencyFractionDigits,
@@ -57,15 +58,18 @@ describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyCon
5758
});
5859

5960
it('THE noisy-shape guard: an untouched fixed-JPY config (defaulted precision) parses clean', () => {
60-
// The baked default 2 "contradicts" JPY's 0 digits — but it was never
61-
// authored, so the rule must not fire. This is the assertion that proves
62-
// the pre-default anchoring; with a property-level `.default(2)` it goes
63-
// red (measured in this card's reverse verification).
61+
// The default 2 "contradicts" JPY's 0 digits — but it was never authored,
62+
// so the rule must not fire. This is the assertion that proves the
63+
// pre-default anchoring; with a property-level `.default(2)` it goes red
64+
// (measured in this card's reverse verification). Since #11423 the default
65+
// is also no longer MATERIALIZED on this combination (the schema would
66+
// refuse it as authored — see the idempotency block below), so the parsed
67+
// output omits `precision` rather than carrying 2.
6468
const result = CurrencyConfigSchema.safeParse({
6569
currencyMode: 'fixed', defaultCurrency: 'JPY',
6670
});
6771
expect(result.success).toBe(true);
68-
expect(result.data!.precision).toBe(2);
72+
expect(result.data!.precision).toBeUndefined();
6973
});
7074

7175
it('dynamic currencyMode is out of reach by design (JPY + 2 + dynamic passes)', () => {
@@ -107,16 +111,22 @@ describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyCon
107111

108112
it('agreeing combos parse byte-identically to the `.default(2)` era', () => {
109113
// Measured on origin/main (37b82ed5b) before this change — same shape
110-
// order, same materialized default, byte for byte.
114+
// order, same materialized default, byte for byte. The one #11423 flip is
115+
// deliberately NOT in this battery: a bare fixed-JPY config now omits
116+
// `precision` (the schema would refuse the materialized 2 as authored —
117+
// pinned in the idempotency block below); every combination here either
118+
// authored its precision or cannot be refused, so byte-identity holds.
111119
const cases: Array<[Record<string, unknown>, string]> = [
112120
[{ precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' },
113121
'{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'],
114122
[{ precision: 0, currencyMode: 'fixed', defaultCurrency: 'JPY' },
115123
'{"precision":0,"currencyMode":"fixed","defaultCurrency":"JPY"}'],
116124
[{ precision: 3, currencyMode: 'fixed', defaultCurrency: 'KWD' },
117125
'{"precision":3,"currencyMode":"fixed","defaultCurrency":"KWD"}'],
126+
[{ currencyMode: 'fixed', defaultCurrency: 'USD' },
127+
'{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'],
118128
[{ currencyMode: 'fixed', defaultCurrency: 'JPY' },
119-
'{"precision":2,"currencyMode":"fixed","defaultCurrency":"JPY"}'],
129+
'{"currencyMode":"fixed","defaultCurrency":"JPY"}'],
120130
[{}, '{"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}'],
121131
];
122132
for (const [input, expected] of cases) {
@@ -140,6 +150,78 @@ describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyCon
140150
});
141151
});
142152

153+
// [#11423] (maintainer ruling routed from #9689, 2026-08-24, idempotent
154+
// materialization): the `.overwrite()` never materializes a default the schema
155+
// itself would refuse as authored. Baking `precision: 2` onto a bare fixed
156+
// zero-/three-digit-currency config (JPY/KRW/KWD class) made parse output
157+
// self-rejecting on re-parse — `parse(parse(x))` threw for accepted x, and the
158+
// re-parse chain is the mainline authoring path (`ObjectSchema.create()`
159+
// returns parse output; `objectstack build`'s defineStack parses it again).
160+
// Same one-conditional shape as the #9689 master_detail guard in field.zod.ts.
161+
describe('#11423 — the materialized precision default is never one the schema itself refuses', () => {
162+
it('a bare fixed-JPY config parses green and OMITS precision — parse(parse(x)) is idempotent', () => {
163+
// The card's measured break: parse #1 baked `precision: 2`, parse #2
164+
// rejected it at `currencyConfig.precision` ("currency JPY has 0 fraction
165+
// digits; `precision: 2` contradicts it"). Absent is the honest spelling.
166+
const once = CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'JPY' });
167+
expect(once.precision).toBeUndefined();
168+
expect('precision' in once).toBe(false);
169+
const again = CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once)));
170+
expect(again.success).toBe(true);
171+
expect(JSON.stringify(again.data)).toBe(JSON.stringify(once));
172+
});
173+
174+
it('parse is IDEMPOTENT through the mainline create() → defineStack chain (the chain that carried the defect)', () => {
175+
const field = FieldSchema.parse({
176+
name: 'amount', label: 'Amount', type: 'currency',
177+
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' },
178+
});
179+
expect(FieldSchema.safeParse(JSON.parse(JSON.stringify(field))).success).toBe(true);
180+
const obj = ObjectSchema.create({
181+
name: 'invoice', label: 'Invoice',
182+
fields: { amount: { label: 'Amount', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } } },
183+
});
184+
expect(ObjectSchema.safeParse(obj).success).toBe(true);
185+
});
186+
187+
it('an AUTHORED contradictory precision is still rejected with the named message (the guard narrows materialization, not the rule)', () => {
188+
const result = CurrencyConfigSchema.safeParse({
189+
precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY',
190+
});
191+
expect(result.success).toBe(false);
192+
const issue = firstIssue(result)!;
193+
expect(issue.code).toBe('custom');
194+
expect(issue.path).toEqual(['precision']);
195+
expect(issue.message).toContain('currency JPY has 0 fraction digits');
196+
expect(issue.message).toContain('`precision: 2` contradicts it');
197+
});
198+
199+
it('a bare fixed-USD config still materializes precision 2 byte-identically (the default keeps baking where it is legal — #7918 relocation intact)', () => {
200+
expect(JSON.stringify(CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'USD' })))
201+
.toBe('{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}');
202+
});
203+
204+
it('the whole refused class skips materialization — 0-digit (KRW) and 3-digit (KWD) fixed currencies omit precision and re-parse green', () => {
205+
for (const code of ['KRW', 'KWD']) {
206+
const once = CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: code });
207+
expect('precision' in once).toBe(false);
208+
expect(CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once))).success).toBe(true);
209+
}
210+
});
211+
212+
it('combinations the superRefine cannot refuse keep materializing — dynamic mode and unknown fixed codes', () => {
213+
// dynamic + JPY: no single currency to check against, baked 2 re-parses
214+
// green (the superRefine only judges `fixed`); unknown fixed code: the
215+
// digit table fails OPEN, so 2 is never refused.
216+
expect(CurrencyConfigSchema.parse({ defaultCurrency: 'JPY' }).precision).toBe(2);
217+
expect(CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'BTC' }).precision).toBe(2);
218+
for (const input of [{ defaultCurrency: 'JPY' }, { currencyMode: 'fixed', defaultCurrency: 'BTC' }]) {
219+
const once = CurrencyConfigSchema.parse(input);
220+
expect(CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once))).success).toBe(true);
221+
}
222+
});
223+
});
224+
143225
describe('#7918 — field-level anchor (FieldSchema.superRefine; the key has no default)', () => {
144226
const base = { name: 'amount', label: 'Amount', type: 'currency' as const };
145227

packages/spec/src/data/field.zod.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -288,23 +288,52 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({
288288
if (contradiction !== undefined) {
289289
ctx.addIssue({ code: 'custom', path: ['precision'], message: contradiction });
290290
}
291-
}).overwrite((config) => ({
291+
}).overwrite((config) => {
292292
// #7918 — the relocated `.default(2)`, applied AFTER the check above.
293293
// `.overwrite()` rather than `.transform()` per the measured #6926 precedent
294294
// (view.zod.ts `foldFormGroupsIntoSections`): it keeps this schema a
295295
// `ZodObject` (a pipe has no `.extend` and answers shape introspection with
296296
// an empty set), and checks run in attachment order, so the superRefine
297297
// above always sees the pre-materialized value. Rebuilt in shape order so
298298
// the output is byte-identical to the `.default(2)` era:
299-
// `{precision, currencyMode, defaultCurrency}`, `precision` always a number.
300-
// The one accepted cost, same as #6926's: the INFERRED output type still
301-
// declares `precision?` even though a parsed config always carries it
302-
// (ADR-0122 forbids hand-narrowing `CurrencyConfigParsed`); the runtime
303-
// contract is the enforced one.
304-
precision: config.precision ?? 2,
305-
currencyMode: config.currencyMode,
306-
defaultCurrency: config.defaultCurrency,
307-
})));
299+
// `{precision, currencyMode, defaultCurrency}`, `precision` always a number
300+
// — except on the guarded combination below. The one accepted cost, same as
301+
// #6926's: the INFERRED output type still declares `precision?` even though
302+
// a parsed config normally carries it (ADR-0122 forbids hand-narrowing
303+
// `CurrencyConfigParsed`); the runtime contract is the enforced one.
304+
//
305+
// #11423 (maintainer ruling on #9689, 2026-08-24, routed to this twin —
306+
// 「The same principle prescribes the fix for the #7918 currency twin
307+
// (#11423) — the spec seat should route it under this ruling.」): NEVER
308+
// materialize a default the schema itself would refuse as authored. The
309+
// superRefine above rejects an AUTHORED `precision: 2` on a fixed
310+
// zero-/three-fraction-digit currency (JPY/KRW/KWD class), and the two
311+
// spellings are indistinguishable to any later parse BY DESIGN — so baking
312+
// `2` onto a bare fixed-JPY config made parse output self-rejecting on
313+
// re-parse, and `ObjectSchema.create()` → `defineStack` re-parses on the
314+
// MAINLINE app-build path (measured: `parse(parse(x))` threw at
315+
// `currencyConfig.precision` for accepted x). A bare fixed config whose
316+
// currency contradicts the default 2 therefore parses to output that OMITS
317+
// `precision`: renderers already derive display width from the currency
318+
// when the key is absent, and built artifacts stop carrying a value the
319+
// schema itself refuses. Every other combination keeps byte-identity —
320+
// `dynamic` mode and unknown codes (fail-open table) can never be refused,
321+
// so they keep materializing. The #9689 master_detail `deleteBehavior`
322+
// conditional in `FieldSchema`'s `.overwrite()` below is the worked
323+
// precedent; #11423 is its recorded currency twin.
324+
if (
325+
config.precision === undefined &&
326+
config.currencyMode === 'fixed' &&
327+
currencyPrecisionContradiction(config.defaultCurrency, 2) !== undefined
328+
) {
329+
return config;
330+
}
331+
return {
332+
precision: config.precision ?? 2,
333+
currencyMode: config.currencyMode,
334+
defaultCurrency: config.defaultCurrency,
335+
};
336+
}));
308337

309338
/**
310339
* Currency Value Schema

0 commit comments

Comments
 (0)