Skip to content

Commit 84156c7

Browse files
fix(spec): a currency field's precision is total digits — stop refusing precision: 18 on a fixed-USD field (#20011) (#20209)
Fixes #20011 Clause-②: yes `FieldSchema` stops comparing a currency field's field-level `precision` with the ISO 4217 fraction digits of its fixed currency. `precision: 18` on a fixed-USD field now parses. `currencyConfig.precision` is a different key, and its check is untouched. ## What was wrong (reproduced on `origin/main` `e4471e64`, before any edit) `FieldSchema.safeParse` through the source, one field per row: | input (`type: 'currency'`) | result on `e4471e64` | |:--|:--| | `precision: 18`, fixed USD | REFUSED at `['precision']` | | `precision: 2`, fixed USD | accepted | | `precision: 1`, fixed USD | REFUSED at `['precision']` | | `precision: 18`, fixed JPY | REFUSED at `['precision']` | | `precision: 0`, fixed JPY | accepted | | `precision: 18`, dynamic | accepted | | `precision: 18`, no `currencyConfig` | accepted | | `type: 'number'`, `precision: 18` | accepted | The refusal text for the first row, which this PR removes from the field-level path: > currency USD has 2 fraction digits; `precision: 18` 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: 2`, 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. The key is declared `precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer)')`. A DECIMAL(18,2) amount is `precision: 18`, and the remedy asked for a total-digit count of 2. Ruling 乙 on #19910 (`5805782503`): 「## Ruled — 乙: a currency's decimal places are the currency's, not a setting」. **Real producer:** objectui's Studio field designer offers a "Precision" control on every numeric type, currency included (`packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx:917-919` at the pin `f8a9d0fb`, `patchDef({ precision: v })`). An admin who types 18 on a fixed-USD currency field gets this refusal at save. ## The premise, re-measured (it is false everywhere) The check's own comment named its premise: 「the FIELD-level `precision` key doubles as the currency display width — objectui's CurrencyField reads it」. Before removing it, every face was checked for a read of field-level `precision` as decimal places. None was found. **objectui at the console pin `f8a9d0fb0596`** (`.objectui-sha` on `main`). `git grep -w precision` over non-test `packages/**` and `apps/**` `*.ts` / `*.tsx` gives 83 lines. Each one is one of these: - **A comment or doc.** For example, `CurrencyField.tsx:64-78` says 「⛔ NOT the field-level `precision`」. - **A type declaration.** `types/src/field-types.ts:407,436,446`. - **A pass-through copy** into a metadata object: `ObjectGrid.tsx:2863,3084,3250`, `RelatedList.tsx:1607`, `RecordDetailPanel.tsx:167`, `sectionFields.ts:321`, `ObjectGallery.tsx:367`, `useColumnSummary.ts:492`. - **The designer's write** above. - **`formatPercent`'s own parameter** of that name. Its callers pass `scale`. The faces that format numbers: - `CurrencyField` derives its width from `currencyFractionDigits(currency)`. - `CurrencyCellRenderer` calls `formatCurrency(num, currency, locale)` with no width argument. - `NumberCellRenderer` and `PercentCellRenderer` read `scale`. - The grid footer's currency arm calls `formatCurrency` and reads neither key. - `ObjectMetricWidget` resolves only the currency. - The detail chip reads `scale`. **objectstack.** No `packages/**` source reads the field-level key. `NUMERIC_COLUMN_REPRESENTATION.currency` is a fixed `(65, 30)` column. `driver-sql/src/builtin-column-collision.ts:77` says `precision` is 「this driver does not read it yet」. objectql has no write-seam total-digit check. So the check guarded a reading that no longer exists. What it still did was refuse the ruled contract. ## Decision: delete the check, keep no residual Zone 2 of the dispatch asked whether some residual stays true, for example `precision` below the currency's fraction digits. Measured: **no numeric type has a total-digit coherence rule.** `{ type: 'number', precision: 1, scale: 2 }` parses on `main`, and so does `{ type: 'percent', precision: 0, scale: 3 }`. Four-axis reading: - **Business need.** Nothing reads the key, so a residual guards the internal consistency of a value with no effect. The census below finds no authored producer in any of the three repos. - **Long-term fit.** A total-digit coherence rule belongs next to a total-digit enforcement (DDL or the write seam), and neither exists. A currency-only, fixed-mode-only remnant would be the one such rule in the spec. - **AI-authoring safety.** The mistake an AI plausibly makes is `precision: 2` meaning "decimals". Refusing that would narrow the accept set, which neither this card nor the dispatch authorizes; the dispatch requires that `precision: 2` keep parsing. The larger trap is that the key is inert everywhere. That goes to the seat as a finding (Acceptance notes), not a new rule here. - **Startup focus.** A new refusal rule is a new gate, and the default is no. A comment at the old site records why the comparison is absent, so it is not reinstated as an "improvement". ## Changes - `packages/spec/src/data/field.zod.ts`: the field-level `currencyPrecisionContradiction` call inside `FieldSchema`'s `.superRefine` is deleted, and a comment replaces it. The import comment is updated. **Region fence:** hunks only at the import comment and the old anchor. There is no hunk in `CurrencyConfigSchema` (its `.superRefine` / `.overwrite` and the `precision` default, #19992's region), in `SelectOptionSchema`, or in the `requiredWhen` / `readonlyWhen` region. - `packages/spec/src/data/currency-fraction-digits.ts`: doc comments only. The helper keeps both of its readers, in `CurrencyConfigSchema`'s `.superRefine` and `.overwrite`. The docblock no longer says "shared by BOTH anchors", and it now states that the verdict is ⛔ not for the field-level key. The message text is unchanged. - `packages/spec/src/data/currency-precision-iso4217.test.ts`: the field-level block is rewritten (pins below). The header docblock is updated. - `content/docs/data-modeling/field-types.mdx` and `validation-rules.mdx`: both claimed a declared `precision` must agree with the currency. This PR makes that false for the field-level key, so the text now names `currencyConfig.precision`. It also says the field-level key is total digits and is never compared. New text in `field-types.mdx`: > A currency amount's decimal places are its currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD), not a field setting, so a `currency` field takes no `scale`: publish-time validation refuses the key. The field-level `precision` is the amount's total digit count — a DECIMAL(18,2) amount declares `precision: 18` — and is never compared with the currency. The same section's table also listed `scale` ("Decimal places") as a currency property. That contradicts the new paragraph and the landed spec refusal (`field.zod.ts`, `scale` on `currency` is refused, with the same wording in `protocol/objectql/schema.mdx`), so the row is deleted. - `.changeset/20011-currency-field-precision-total-digits.md`: `@objectstack/spec` minor, `Clause-②: yes (widening)`. Nothing is narrowed, so there is no ADR-0087 entry (`check:adr-0087-registration` exit 0). ## Pins (`currency-precision-iso4217.test.ts`, block `#20011 — field-level precision is total digits…`) - `precision: 18` on fixed USD parses, and `data.precision` is still 18. - `precision: 2` on fixed USD still parses. - **Flipped:** `precision: 2` on fixed JPY parses (DECIMAL(2,0)). This is the old `rejects … (JPY + 2)` pin. - Total digits parse on every fraction-digit class: JPY 0 / 12, USD 2 / 18, KWD 3 / 2 / 15, each with the authored value carried through. - No currency-only coherence rule: USD `precision: 1` parses, as `number` with `precision: 1, scale: 2` does. - Outside fixed mode (no `currencyConfig`, dynamic authored or defaulted), nothing changes. Non-currency types are untouched. - Through the object door: `ObjectSchema.create` with a fixed-USD `precision: 18` field, then `ObjectSchema.safeParse` of the JSON round-trip, succeeds and keeps 18. - **Kept refusal (genuine, a different key):** `currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' }` through `FieldSchema` is refused at `['currencyConfig', 'precision']`, code `custom`. The message contains `currency JPY has 0 fraction digits` and the prescription `Declare \`precision: 0\``. - **Flipped:** the old `both anchors fire independently` pin. With both keys authored, exactly one issue is raised, at `['currencyConfig', 'precision']` (code + path + prescription asserted). The field-level key raises nothing. - The `#7918` `currencyConfig`-level block, the `#11423` materialization block and the digit-table block are untouched and green. **Pin sweep.** Searched for `fraction digits; ` and `contradicts it —` across objectstack, objectui at the pin, and cloud `48d70663`. The only field-level pins were in this test file. The other `currencyMode: 'fixed'` fixtures (`field.test.ts`, `field-currency-scale-refused.test.ts`, three `service-analytics` tests) carry no field-level `precision`. ## Reverse verification Run on the committed fix `accdab16` with `scripts/ablation-replace.mjs` plus its own `trap` restore. The mutation re-inserts the old field-level check under a marker. The marker count was 0, then 1 during the run, then 0 after. The blob went `26f8ef39` → `5c392b24` and back to `26f8ef39`. Expected direction: red. Observed: **6 failed, 24 passed**, and the six are exactly the new acceptance pins. Restore was proven by blob == HEAD and an empty `git diff HEAD`, not by an exit code. The first attempt was a no-op. My replacement contained the anchor text, so the anchor count could not drop, and the tool refused it before running the test. It was redone with a replacement that drops the anchor. The later merge of `main` touched neither mutated file. ## Producer census (authored currency fields with a field-level `precision`) The detector parses `Field.currency({…})` and `type: 'currency'` object literals and reads their top-level keys. | tree | non-test currency declarations | with field-level `precision` | control (tests, same detector) | |:--|--:|--:|:--| | objectstack `e4471e64` (`examples/**`, `packages/**`) | 31 | 0 | 194 declarations, 2 hits (`object.test.ts:800` = 18, `lint-liveness-properties.test.ts:780` = 2) | | objectui `f8a9d0fb` | 5 | 0 | 180 declarations, 14 hits | | cloud `48d70663` | 0 | 0 | 44 declarations, 0 hits; no non-test field `precision` anywhere in `packages/**` | The one live producer is the Studio designer control named above. ## Verification (final head `0e006877`, after merging `main` at `172b4cf3`) - `@objectstack/spec` suite (`vitest run --project local`): **540 files, 15871 passed, 2 todo**. - `@objectstack/spec` typecheck (`tsc --noEmit`, scripts, test layer): exit 0, and the test-layer debt ledger held. - `check:generated`: all 15 generated artifacts up to date. - Consumer suites: `@objectstack/lint` **108 files / 4183 passed**; `@objectstack/platform-objects` **55 / 911 passed**. - Gates: `dispatch-gates.mjs --commands` derived **104** commands for these 6 paths. **All 104 exit 0 at `0e006877`**. `--ran` reconciliation: 104 derived, 104 run, 0 NOT MEASURED, a derived zero because every line recorded its exit code. - At the final head, one first run (`check:dual-build-cjs-loads`) exited 3 (`PREREQUISITE NOT MET`). It was re-run to 0 once the packages it named were built. - The pre-merge pass at `accdab16` had seven such refusals, all cleared the same way. - None of those refusals counts as a measurement. - ESLint, narrowed to the three touched `.ts` files: - All three resolve a config under `--print-config`. - `--format json` reports 3 files, 0 errors, 0 warnings. - `eslint.config.mjs` never enables type-aware linting (its own note at `:326-328`), so this diff cannot move any untouched file's verdict. - `pnpm lint` over the whole repo is CI's run. - NOT MEASURED locally, declared to CI: - Dogfood. No dogfood test pins currency `precision`. - The workspace, examples and downstream-contract type-check lanes. - The CI-only jobs these paths schedule. ## Acceptance notes - **Finding for the seat (not filed here): the field-level `precision` key has no reader at all.** It is declared "Total digits (non-negative integer)" and the Studio designer writes it on every numeric type. Nothing enforces it: not DDL (fixed `(65, 30)`), not the write seam, and no display face. `packages/spec/liveness/field.json` `props.precision` still reads `live`, citing objectui `@11c1e71` reads (`CurrencyField.tsx:45`, `PercentField.tsx:13`, `index.tsx:662`, `useColumnSummary.ts:261`), all of which are gone at `f8a9d0fb`. This is the same family as #19992, which covers `currencyConfig.precision` with no reader. Details are in the dev report on the card. - #19992 (the `CurrencyConfigSchema.precision` anchor) is not addressed here and remains open. Its region is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7344f0 commit 84156c7

6 files changed

Lines changed: 138 additions & 76 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): a currency field's `precision` is its total digit count again — `precision: 18` on a fixed-USD field parses instead of being refused as a contradiction of the currency's two decimal places (#20011)
6+
7+
Clause-②: yes (widening) — one refusal is removed from `FieldSchema`, so the accepted set grows. Nothing that parsed before is refused now, no key is renamed or retired, and the output of every previously accepted field is byte-identical.
8+
9+
## What was wrong
10+
11+
`FieldSchema` compared a currency field's field-level `precision` with the ISO 4217 fraction digits of its fixed currency, and refused any difference. The key is declared `Total digits (non-negative integer)`, so a DECIMAL(18,2) USD amount writes `precision: 18`. That field was refused with "currency USD has 2 fraction digits; `precision: 18` contradicts it", and the refusal told the author to write `precision: 2`, which is a total-digit count of 2.
12+
13+
The check assumed the field-level key was the currency display width, because the Studio currency widget used to read it that way. That reading is gone. The ruled contract is that a currency amount's decimal places come from the currency and are not a field setting. No renderer reads the field-level `precision` as decimal places, and the SQL column does not read it at all.
14+
15+
## What it does now
16+
17+
- The field-level `precision` on a `currency` field is not compared with the currency. Any non-negative integer parses in every currency mode and is carried through unchanged.
18+
- `currencyConfig.precision` is a different key and is unchanged. Under `currencyMode: 'fixed'` it must still agree with the currency's fraction digits, and a contradiction is still refused at `currencyConfig.precision` with the same message.
19+
- `scale` on a `currency` field is still refused.
20+
21+
Nothing to migrate. Metadata that parsed before parses the same way. A currency field that had to drop `precision` or set it to the currency's fraction digits to get through validation can now declare its real total digit count.

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ code lives in `currencyConfig`, never in the value.
149149
| Property | Type | Default | Description |
150150
|:---|:---|:---|:---|
151151
| `precision` | `number` | — | Total digits |
152-
| `scale` | `number` | — | Decimal places |
153152
| `min` | `number` | — | Minimum value |
154153
| `max` | `number` | — | Maximum value |
155154
| `currencyConfig.precision` | `number` | `2` | Decimal precision for currency |
@@ -160,13 +159,20 @@ code lives in `currencyConfig`, never in the value.
160159
{ name: 'price', label: 'Price', type: 'currency', currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' } }
161160
```
162161

163-
A declared `precision` must agree with the currency's ISO 4217 fraction digits
164-
when the currency is statically known — that is, when `currencyConfig` is in
165-
`fixed` mode. Publish-time validation rejects the contradiction, naming both
166-
numbers ("currency JPY has 0 fraction digits; `precision: 2` contradicts it"):
167-
the yen has no minor unit to show two digits of, and `precision: 2` on a KWD
168-
field would silently drop the third fils digit that exists. Omit `precision`
169-
to let renderers derive the width from the currency itself. The rule is
162+
A currency amount's decimal places are its currency's ISO 4217 minor unit
163+
(2 for USD, 0 for JPY, 3 for KWD), not a field setting, so a `currency` field
164+
takes no `scale`: publish-time validation refuses the key. The field-level
165+
`precision` is the amount's total digit count — a DECIMAL(18,2) amount declares
166+
`precision: 18` — and is never compared with the currency.
167+
168+
A declared `currencyConfig.precision` must agree with the currency's ISO 4217
169+
fraction digits when the currency is statically known — that is, when
170+
`currencyConfig` is in `fixed` mode. Publish-time validation rejects the
171+
contradiction, naming both numbers ("currency JPY has 0 fraction digits;
172+
`precision: 2` contradicts it"): the yen has no minor unit to show two digits
173+
of, and `precision: 2` on a KWD field would silently drop the third fils digit
174+
that exists. Omit `currencyConfig.precision` to let renderers derive the width
175+
from the currency itself. The rule is
170176
deliberately partial: a field in `dynamic` currencyMode has no single currency
171177
to check against, and currency codes outside CLDR `currencyData`
172178
(cryptocurrency or custom business codes) are not checked. The rule judges

‎content/docs/data-modeling/validation-rules.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ These properties apply to **all** field types and are validated by the base `Fie
152152
| `min` | `number` | — | Minimum monetary value |
153153
| `max` | `number` | — | Maximum monetary value |
154154
| `currencyConfig.precision` | `number` | `2` | Decimal places (0–10) |
155-
| `currencyConfig.currencyMode` | `enum` | `dynamic` | `fixed` pins the field to `defaultCurrency`, and an authored `precision` must agree with that currency's ISO 4217 fraction digits; `dynamic` displays the tenant default currency (the `localization.currency` setting). Not a per-record choice |
155+
| `currencyConfig.currencyMode` | `enum` | `dynamic` | `fixed` pins the field to `defaultCurrency`, and an authored `currencyConfig.precision` must agree with that currency's ISO 4217 fraction digits (the field-level `precision`, total digits, is not compared); `dynamic` displays the tenant default currency (the `localization.currency` setting). Not a per-record choice |
156156
| `currencyConfig.defaultCurrency` | `string` | `CNY` | 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`; not the displayed currency under `dynamic` |
157157

158158
**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. Precision defaults to 2 decimal places.

‎packages/spec/src/data/currency-fraction-digits.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
/**
44
* ISO 4217 / CLDR currency fraction digits — the static digit table behind the
55
* #7918 publish-time rule (maintainer ruling 2026-08-12, Option A): a currency
6-
* field whose AUTHORED `precision` contradicts its statically-known currency's
7-
* fraction digits is rejected at parse/publish time, with a message naming
8-
* both numbers.
6+
* field whose AUTHORED `currencyConfig.precision` contradicts its
7+
* statically-known currency's fraction digits is rejected at parse/publish
8+
* time, with a message naming both numbers. The FIELD-level `precision` key is
9+
* not judged here: it is "Total digits", not decimal places (#20011).
910
*
1011
* ## Provenance — CLDR `currencyData`, checked in, not probed at runtime
1112
*
@@ -84,12 +85,16 @@ export function currencyFractionDigits(code: string): number | undefined {
8485
}
8586

8687
/**
87-
* The #7918 verdict, shared by BOTH anchors of the rule — the field-level
88-
* `precision` key (checked in `FieldSchema`'s `superRefine`) and
89-
* `CurrencyConfigSchema.precision` (checked pre-default inside that schema) —
90-
* so the two doors cannot drift apart in wording. Returns the issue message
91-
* when `precision` contradicts `currency`'s fraction digits, `undefined` when
92-
* they agree or the currency is unknown (fail-open).
88+
* The #7918 verdict for `CurrencyConfigSchema.precision`, checked pre-default
89+
* inside that schema (its `.superRefine`, and the `.overwrite` that decides
90+
* whether the default 2 may be materialized). Returns the issue message when
91+
* `precision` contradicts `currency`'s fraction digits, `undefined` when they
92+
* agree or the currency is unknown (fail-open).
93+
*
94+
* ⛔ Not for the FIELD-level `precision` key. That key is "Total digits" — a
95+
* DECIMAL(18,2) amount declares `precision: 18` — so comparing it with a
96+
* fraction-digit count refuses correct metadata and prescribes a total-digit
97+
* count of 2. `FieldSchema` ran this verdict on it until #20011.
9398
*
9499
* The message's first clause names both numbers, verbatim per the ruling:
95100
* "currency JPY has 0 fraction digits; `precision: 2` contradicts it".

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

Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
/**
44
* #7918 (maintainer ruling 2026-08-12, Option A) — publish-time rejection of a
5-
* declared currency `precision` that contradicts the currency's ISO 4217 /
6-
* CLDR fraction digits, when the currency is statically known
5+
* declared `currencyConfig.precision` that contradicts the currency's ISO 4217
6+
* / CLDR fraction digits, when the currency is statically known
77
* (`currencyConfig.currencyMode: 'fixed'`).
88
*
9+
* #20011 — the FIELD-level `precision` key is NOT judged by this rule. It is
10+
* "Total digits" (the `p` of a DECIMAL(p, s) amount), and a currency's decimal
11+
* places are the currency's, not a setting (ruling 5805782503, letter 乙). The
12+
* field-level block below pins that it parses whatever the currency.
13+
*
914
* The rule is deliberately partial: `dynamic` currencyMode has no single
1015
* currency to check against and is out of reach BY DESIGN; codes outside CLDR
1116
* `currencyData` (crypto/custom) fail OPEN. The rule fires ONLY on an AUTHORED
@@ -222,44 +227,65 @@ describe('#11423 — the materialized precision default is never one the schema
222227
});
223228
});
224229

225-
describe('#7918 — field-level anchor (FieldSchema.superRefine; the key has no default)', () => {
230+
describe('#20011 — field-level `precision` is total digits, never judged against the currency (FieldSchema)', () => {
231+
// Ruling 5805782503 (letter 乙): a currency's decimal places are the
232+
// currency's, not a setting — and the key's own describe is "Total digits".
233+
// The #7918 check that compared this key with the fixed currency's fraction
234+
// digits refused `precision: 18` on a USD amount (a DECIMAL(18,2)) and
235+
// prescribed `precision: 2`; it is gone, and these pins hold it gone.
226236
const base = { name: 'amount', label: 'Amount', type: 'currency' as const };
237+
const fixed = (defaultCurrency: string) => ({ currencyMode: 'fixed' as const, defaultCurrency });
227238

228-
it('rejects an authored field-level precision contradicting the fixed currency (JPY + 2)', () => {
229-
const result = FieldSchema.safeParse({
230-
...base, precision: 2,
231-
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' },
232-
});
233-
expect(result.success).toBe(false);
234-
const issue = firstIssue(result)!;
235-
expect(issue.code).toBe('custom');
236-
expect(issue.path).toEqual(['precision']);
237-
expect(issue.message).toContain('currency JPY has 0 fraction digits');
238-
expect(issue.message).toContain('`precision: 2` contradicts it');
239+
it('accepts `precision: 18` on a fixed-USD field — a DECIMAL(18,2) amount — and keeps the authored 18', () => {
240+
const result = FieldSchema.safeParse({ ...base, precision: 18, currencyConfig: fixed('USD') });
241+
expect(result.success).toBe(true);
242+
// Carried through as authored: nothing rewrites the total-digit count to
243+
// the currency's fraction digits.
244+
expect(result.data!.precision).toBe(18);
239245
});
240246

241-
it('accepts a field-level precision agreeing with the fixed currency (JPY + 0, KWD + 3)', () => {
242-
expect(FieldSchema.safeParse({
243-
...base, precision: 0,
244-
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' },
245-
}).success).toBe(true);
247+
it('still accepts `precision: 2` on a fixed-USD field', () => {
248+
const result = FieldSchema.safeParse({ ...base, precision: 2, currencyConfig: fixed('USD') });
249+
expect(result.success).toBe(true);
250+
expect(result.data!.precision).toBe(2);
251+
});
252+
253+
it('flipped: `precision: 2` on a fixed-JPY field parses — a DECIMAL(2,0) amount — where #7918 refused it', () => {
254+
const result = FieldSchema.safeParse({ ...base, precision: 2, currencyConfig: fixed('JPY') });
255+
expect(result.success).toBe(true);
256+
expect(result.data!.precision).toBe(2);
257+
});
258+
259+
it('accepts a total-digit count on every fraction-digit class (0: JPY, 2: USD, 3: KWD)', () => {
260+
const cases: Array<[string, number]> = [
261+
['JPY', 0], ['JPY', 12], ['USD', 2], ['USD', 18], ['KWD', 3], ['KWD', 2], ['KWD', 15],
262+
];
263+
for (const [code, precision] of cases) {
264+
const result = FieldSchema.safeParse({ ...base, precision, currencyConfig: fixed(code) });
265+
expect(result.success, `${code} + precision ${precision}`).toBe(true);
266+
expect(result.data!.precision).toBe(precision);
267+
}
268+
});
269+
270+
it('adds no currency-only total-digit coherence rule — a count below the fraction digits parses, as `precision` below `scale` does on a number', () => {
271+
// Measured parity, not an endorsement of the value: no numeric type
272+
// relates `precision` to its decimal places at parse, so a currency-only
273+
// rule would be the one such rule, on a key no face reads.
274+
expect(FieldSchema.safeParse({ ...base, precision: 1, currencyConfig: fixed('USD') }).success).toBe(true);
246275
expect(FieldSchema.safeParse({
247-
...base, precision: 3,
248-
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'KWD' },
276+
name: 'qty', label: 'Qty', type: 'number' as const, precision: 1, scale: 2,
249277
}).success).toBe(true);
250278
});
251279

252-
it('no currencyConfig ⇒ no statically-known currency ⇒ no verdict', () => {
280+
it('unchanged outside fixed mode: no currencyConfig, and dynamic (authored or defaulted)', () => {
253281
expect(FieldSchema.safeParse({ ...base, precision: 2 }).success).toBe(true);
254-
});
255-
256-
it('dynamic (authored or defaulted) currencyMode ⇒ no verdict', () => {
282+
expect(FieldSchema.safeParse({ ...base, precision: 18 }).success).toBe(true);
257283
expect(FieldSchema.safeParse({
258284
...base, precision: 2,
259285
currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'JPY' },
260286
}).success).toBe(true);
261287
expect(FieldSchema.safeParse({
262-
...base, precision: 2,
288+
...base, precision: 18,
263289
currencyConfig: { defaultCurrency: 'JPY' },
264290
}).success).toBe(true);
265291
});
@@ -270,7 +296,17 @@ describe('#7918 — field-level anchor (FieldSchema.superRefine; the key has no
270296
}).success).toBe(true);
271297
});
272298

273-
it('the currencyConfig-level check reaches through the FieldSchema door (path is prefixed)', () => {
299+
it('crosses the object door (ObjectSchema.create) and re-parses stably with the authored 18', () => {
300+
const obj = ObjectSchema.create({
301+
name: 'invoice', label: 'Invoice',
302+
fields: { amount: { label: 'Amount', type: 'currency', precision: 18, currencyConfig: fixed('USD') } },
303+
});
304+
const reparsed = ObjectSchema.safeParse(JSON.parse(JSON.stringify(obj)));
305+
expect(reparsed.success).toBe(true);
306+
expect(reparsed.data!.fields.amount.precision).toBe(18);
307+
});
308+
309+
it('the currencyConfig-level check still reaches through the FieldSchema door (a different key; unchanged)', () => {
274310
const result = FieldSchema.safeParse({
275311
...base,
276312
currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' },
@@ -280,17 +316,21 @@ describe('#7918 — field-level anchor (FieldSchema.superRefine; the key has no
280316
expect(issue.code).toBe('custom');
281317
expect(issue.path).toEqual(['currencyConfig', 'precision']);
282318
expect(issue.message).toContain('currency JPY has 0 fraction digits');
319+
expect(issue.message).toContain('Declare `precision: 0`');
283320
});
284321

285-
it('both anchors fire independently when both keys contradict', () => {
322+
it('with both keys authored, only the currencyConfig anchor fires — the field-level key raises nothing', () => {
286323
const result = FieldSchema.safeParse({
287324
...base, precision: 2,
288325
currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' },
289326
});
290327
expect(result.success).toBe(false);
291-
const paths = result.error!.issues.map((i) => i.path.join('.'));
292-
expect(paths).toContain('precision');
293-
expect(paths).toContain('currencyConfig.precision');
328+
const issues = result.error!.issues;
329+
expect(issues).toHaveLength(1);
330+
expect(issues[0].code).toBe('custom');
331+
expect(issues[0].path).toEqual(['currencyConfig', 'precision']);
332+
expect(issues[0].message).toContain('currency JPY has 0 fraction digits');
333+
expect(issues[0].message).toContain('Declare `precision: 0`');
294334
});
295335
});
296336

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

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ import {
2929
} from './default-value-shape';
3030
import { AddressSchema, FILE_REFERENCE_TYPES, MULTI_CAPABLE_TYPES, MULTI_OPTION_TYPES, REFERENCE_VALUE_TYPES } from './field-value.zod';
3131
// #7918 — the ISO 4217 / CLDR fraction-digit contradiction check (maintainer
32-
// ruling 2026-08-12, Option A). One shared verdict for both anchors: the
33-
// field-level `precision` key and `CurrencyConfigSchema.precision`.
32+
// ruling 2026-08-12, Option A), read by `CurrencyConfigSchema.precision`'s
33+
// anchor. The FIELD-level `precision` key is total digits and is not compared
34+
// against the currency (#20011 — see the note in `FieldSchema`'s superRefine).
3435
import { currencyPrecisionContradiction } from './currency-fraction-digits';
3536
import { ValueDomainSchema } from '../shared/value-domain.zod';
3637

@@ -2306,31 +2307,20 @@ export const FieldSchema = lazySchema(() => {
23062307
});
23072308
}
23082309

2309-
// #7918 (maintainer ruling 2026-08-12, Option A): the FIELD-level
2310-
// `precision` key doubles as the currency display width — objectui's
2311-
// CurrencyField reads it, and objectui#4361 pinned authored-precision-wins
2312-
// there — so an authored value contradicting the statically-known currency's
2313-
// ISO 4217 / CLDR fraction digits is rejected at this seam too. The currency
2314-
// is statically known only under `currencyConfig.currencyMode: 'fixed'`
2315-
// (`dynamic` is out of reach BY DESIGN; a field with no `currencyConfig` has
2316-
// only the runtime tenant default, which is not static). This key has NO
2317-
// schema default, so `undefined` here IS "not authored" — the
2318-
// authored-vs-defaulted trap lives entirely on the `currencyConfig` twin of
2319-
// this check, which runs pre-default inside `CurrencyConfigSchema` itself.
2320-
// Unknown currency codes fail OPEN (see currency-fraction-digits.ts).
2321-
if (
2322-
field.type === 'currency' &&
2323-
field.precision !== undefined &&
2324-
field.currencyConfig?.currencyMode === 'fixed'
2325-
) {
2326-
const contradiction = currencyPrecisionContradiction(
2327-
field.currencyConfig.defaultCurrency,
2328-
field.precision,
2329-
);
2330-
if (contradiction !== undefined) {
2331-
ctx.addIssue({ code: 'custom', path: ['precision'], message: contradiction });
2332-
}
2333-
}
2310+
// #20011 — the FIELD-level `precision` on a `currency` field is NOT compared
2311+
// against the currency's ISO 4217 fraction digits, on purpose. The key is
2312+
// declared "Total digits" — the `p` of a DECIMAL(p, s) amount, so
2313+
// `precision: 18` on a fixed-USD field is DECIMAL(18,2) — and a currency's
2314+
// decimal places are the currency's, not a setting (ruling 5805782503,
2315+
// batch #218 item 2, letter 乙). The #7918 check that stood here (maintainer
2316+
// ruling 2026-08-12, Option A) rested on one premise: objectui's
2317+
// CurrencyField read this key as its display width. objectui#10276 retired
2318+
// that reading, and no face reads the key as decimal places, so the check
2319+
// refused the ruled contract and prescribed a total-digit count of 2.
2320+
// ⛔ Do not reinstate a comparison here, and do not add a total-digit
2321+
// coherence rule for currency alone: no numeric type has one. The
2322+
// `currencyConfig.precision` twin is a DIFFERENT key ("Decimal precision")
2323+
// and keeps its own check inside `CurrencyConfigSchema`.
23342324

23352325
// #9689 (maintainer ruling 2026-08-19, Q1 = A): an AUTHORED
23362326
// `deleteBehavior: 'set_null'` on a `master_detail` is a publish-time error.

0 commit comments

Comments
 (0)