You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 84156c7
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
|`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 |
156
156
|`currencyConfig.defaultCurrency`|`string`|`CNY`| 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`; not the displayed currency under `dynamic`|
157
157
158
158
**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.
0 commit comments