Skip to content

Commit b77c160

Browse files
feat(spec)!: refuse a homeAmount rateField declaring scale below 12; document CURRENCY_FRACTION_DIGITS as CLDR fraction digits (#30644)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuV5cRafodM2YQHxeDkdiWJM)_ domain:spec#2 · os-sales · session_01L6bhgVugtCCwBs98FeR27P Fixes #30103 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — `skills/objectstack-data/rules/field-types.md` (L175-178) teaches `rateField` as a readonly `number` with no `scale`, which this diff still accepts; the floor ships in the refusal text, the changeset's FROM → TO and the upgrade-guide entry `home-amount-rate-field-scale-floor`. ## 维护者速读 - **改了什么:** 对象校验新增一条:`homeAmount.rateField` 指向的数字字段若声明 `scale` 小于 12(汇率的小数位,裁决 6469382727),建模时拒收,错误点名字段、下限 12 和改法;未声明 `scale` 照常通过。同时把 `CURRENCY_FRACTION_DIGITS` 与币种文档改写为 CLDR 小数位(不是 ISO 4217 最小单位),并列出实测不同的 16 个币种。 - **为什么改:** 引擎按汇率行原值(12 位小数)存汇率,字段声明的 scale 只影响显示;声明 scale 4 时页面显示 0.1389,与实际折算用的 0.138912345678 不一致,看到的汇率复算不出存下的本币金额。 - **风险与回滚:** 收窄了 18.3.0 已发布的接受集:已声明 scale 0–11 汇率字段的应用会在 `os validate`/保存时报错,改法一行(改成 `scale: 12` 或删掉 `scale`);已存的记录数据不受影响。已构建的旧产物若含此类字段,加载时整份被拒,需修源后重建;`sys_metadata` 里已存的对象行照常加载注册,启动日志打 `[metadata_spec_invalid]`,下次保存被 422 拒收,直到把 scale 提到 12 以上或删掉。回滚即撤销本 PR。 - **先看:** ① `packages/spec/src/data/object.zod.ts` 的 `rateField` 分支(新增 scale 判断);② `packages/spec/src/data/currency-config-home-amount.test.ts` 末尾新 describe;③ `.changeset/30103-home-amount-rate-field-scale-floor.md`。 - **两分钟验证:** `pnpm --filter @objectstack/spec exec vitest run src/data/currency-config-home-amount.test.ts`,预期 38 条全过,其中 scale 4/0/11 被拒、12/14/未声明通过。 - **需要你做的:** 无,席位落地(收窄需席位做一次契约级复审)。 ## What changes - **`refuseDishonestHomeAmount`, the `rateField` arm** (`packages/spec/src/data/object.zod.ts`). After the existing target and writer checks, a rate field declaring `scale` below 12 is refused at `fields.AMOUNT.currencyConfig.homeAmount.rateField`. The message names the field, its `scale`, the floor 12 and the fix (`scale: 12` or more, or delete `scale`). The floor is a module-private `HOME_AMOUNT_RATE_SCALE = 12` citing ruling 6469382727. It restates platform-objects' `EXCHANGE_RATE_SCALE` because spec cannot import that package. A rate column shared by consistent amounts is judged once, like the readonly mark. - **Undeclared `scale` is accepted, and here is the reading behind it.** At `origin/main` `1c5cee67`: - The engine stores the rate row's own value, never rounded (`packages/objectql/src/home-amount-projection.ts` L38-44). - Every `number` column is one exact decimal, `NUMERIC_COLUMN_PRECISION 65` / `NUMERIC_COLUMN_SCALE 30` (`packages/spec/src/data/numeric-column-representation.ts` L174/L181; `driver-sql` `table.decimal(name, 65, 30)`), whatever `scale` the field declares. - The rate row itself carries scale 12 / precision 24 (`sys_exchange_rate.rate`). - The field schema says an omitted `scale` on a non-percent numeric type declares no fixed width. `resolveFieldScale` answers `undefined` for it, and the console's number formatter then uses `maximumFractionDigits: 20`. So an undeclared rate field shows all twelve stored digits. This follows from the existing field contract. It needs no choice beyond the ruling. - **`CURRENCY_FRACTION_DIGITS` TSDoc** (`packages/spec/src/data/currency-fraction-digits.ts`). It now opens "CLDR currency fraction digits — NOT ISO 4217 minor units" and carries a measured table of the 16 differing codes. - **`content/docs/data-modeling/currency.mdx`.** The fraction-digits sentence now lists the same 16 codes. - **Changeset** (`@objectstack/spec` minor, `**BREAKING**`, FROM → TO). It has a semantic upgrade-guide entry `18.home-amount-rate-field-scale-floor.ts` (disposition `registered`). **The 16 codes, measured.** The table's 162 entries were compared with SIX's ISO 4217 List One, published 2024-06-25: the `iso-4217-list-one.xml` that `currency-codes@2.2.0` ships, sha256 `2dea9812…71b8b`. The cross-read used the `datasets/currency-codes` `codes-all.csv` active rows (sha256 `c4b6829a…7407f`). Both give the same set: | ISO 4217 minor unit | CLDR digits | Codes | |:---|:---|:---| | 3 | 0 | IQD | | 2 | 0 | AFN, ALL, COP, HUF, IDR, IRR, KPW, LAK, LBP, MGA, MMK, PKR, SOS, SYP, YER | Not comparable: XDR and XSU (ISO `N.A.`, table 2); HRK, SLL and ZWL are withdrawn and absent from List One. XCG is absent from the 2024-06-25 XML and equals 2 in the CSV. ANG, BGN and CUC are absent from the CSV's active rows and equal in the XML. SIX's own host (`www.six-group.com`) was refused by this container's egress proxy, so the list was read from those two copies. The triage's "about seventeen" included SLL. SLL is withdrawn, so no current ISO minor unit exists to compare it against. ## Verification <details><summary>Census: in-repo <code>homeAmount.rateField</code> targets declaring <code>scale</code> &lt; 12 — 1 found, not changed</summary> I ran `git grep -n "rateField:"` over the whole tree and read every rate field definition: `content/docs`, `skills/`, objectql, cli, plugin-audit, service-analytics, service-settings, dogfood and the spec tests. `examples/` declares no `homeAmount`. - **1 hit:** `packages/objectql/src/engine-home-amount-projection.test.ts` L331, `invoice({ rate: { scale: 4 } })`. It is an engine pin that a scale-4 rate field still stores the full rate (from PR #28302). It registers the object through `engine.registry.registerObject`, which does not run `ObjectSchema` parse, and it stays green (below). It pins what the engine does with an object stored before this release (stored rows are kept as authored). It is outside this PR's file surface (objectql) and is left as is. - Every other carrier is undeclared (`Field.number({ readonly: true })`) and is accepted. </details> <details><summary>Pins (<code>ObjectStackDefinitionSchema.safeParse</code>, the parse <code>os validate</code> and <code>os compile</code> call)</summary> There is one new top-level `describe` in the existing harness file `packages/spec/src/data/currency-config-home-amount.test.ts`: - `scale: 4` is refused. The pin asserts `code: 'custom'`, path `objects.0.fields.amount.currencyConfig.homeAmount.rateField`, the message's first sentence (field, `scale: 4`, floor 12), the prescription, and that the message has no tracker number. - `scale` 0 and 11 are refused at the same path. - `scale` 12 and 14 are accepted, and an undeclared `scale` is accepted. - A shared rate column at `scale: 4` gets exactly one issue. File: 38/38. Full `@objectstack/spec` suite: 816/816 files, 25671 passed, 1 todo. </details> <details><summary>Ablation</summary> `node scripts/ablation-replace.mjs` replaced the arm's condition with `if (false && …)`. The anchor went x1 → x0 and the blob `7f70cb81` → `c41206f1`. Result: **3 red** (the `scale: 4`, the 0/11, and the shared-column pins) and 35 green. The preservation pins stayed green. The tool restored the file: blob == HEAD `7f70cb81`, `git diff HEAD` empty. </details> <details><summary>Dogfood battery and consumer tests</summary> - `pnpm --filter @objectstack/dogfood test` on `997cb02d30`, after a build of the `@objectstack/dogfood^...` closure (exit 0): VERDICT command-exit 0, 420 passed / 3 skipped of 423 files, 3500 passed / 36 skipped of 3536 tests. No failure. - Consumer test files that parse `homeAmount` objects: each exit captured separately, all 0: objectql `engine-home-amount-projection` + `home-amount-projection` + `home-amount-write-paths` 3/3 files, 73/73 (the scale-4 engine pin among them); plugin-audit `home-amount-recompute-audit.integration` 1/1; service-analytics `home-amount-consolidated-measure` 18 passed / 18 skipped (PostgreSQL legs, no `OS_TEST_POSTGRES_URL`); service-settings `localization-currency-lock` 24/24; cli `recompute-home-amounts` 9/9. </details> <details><summary>Gates on <code>997cb02d30</code> (exit code captured before any pipe)</summary> | Family | Exit | |:---|:---| | `pnpm --filter @objectstack/spec build` | 0 | | `pnpm --filter @objectstack/spec check:generated` (14/14 up to date, `--fix` not needed) | 0 | | `dispatch-gates --commands` derived set: 103 commands | 103 × 0 | | `dispatch-gates --ran` reconciliation | 0 (103 derived, 103 run, 0 NOT-MEASURED, 0 UNRUN) | | `pnpm check:adr-0087-registration` | 0 (`registered home-amount-rate-field-scale-floor`, new here) | | `pnpm check:doc-authoring` | 0 | | `node scripts/check-nul-bytes.mjs` | 0 | | eslint `--no-inline-config` on the 4 changed `.ts` files | 0 | Six derived commands first exited **3 (PREREQUISITE NOT MET: unbuilt dists)**. Each was re-run after the closure build, and that re-run is the exit recorded for `--ran`: `check:doc-formula-expressions`, `check:doc-security-posture`, `check:skill-examples`, `check:docs-transcript-drift`, `check:lean-entry-closure`, `check:dual-build-cjs-loads`, all 0. `check:generated` was run at `--max-old-space-size=6144` (CI's figure). Under the default heap, `check:test-typecheck`'s tsc ran out of memory: no verdict, not a finding. </details> ## Acceptance notes - **File surface, one addition.** The ADR-0087 disposition has to be `registered`, because a FROM → TO body refuses `no-migration-prescription`, and every spec `no (narrowing)` changeset in stock is registered. So the diff adds `packages/spec/src/migrations/entries/semantic/18.home-amount-rate-field-scale-floor.ts`, which the generated registry picks up. Nothing else outside the claimed surface is touched. - **`Metadata-protocol: narrowed`**, read from the diff: the change refuses a value an author could write before (`scale` 0–11 on the named rate field). The card body says `none`, and the claim already says `narrowed`. - **Prose still reading "ISO 4217 minor unit" outside this surface.** `git grep -n -i "ISO 4217 minor unit" -- packages/spec/src ':!**/*.test.ts'` gives 20 lines. They are in `field.zod.ts` describes and refusals, five ADR-0087 migration entries, and `registry.ts.template`; `field-types.mdx` L237/L248/L596 and `validation-rules.mdx` L160 say the same. They describe how a currency amount's decimals display, and the console reads those from `Intl` (CLDR). Several quote ruling 5805782503's own remedy words. Noted, not filed. carrier: domain:spec seat. - **`HomeAmountSchema.rateField` `.describe()`** (`field.zod.ts` L657) does not mention the floor. It is outside this surface. The refusal carries the prescription. carrier: domain:spec seat. - **The engine pin at scale 4** (census) stays as an engine-level pin for stored objects that predate this refusal. - **Patch round (contract review ④ 3).** The upgrade entry and the changeset now name the two metadata legs: an older built artifact carrying such a rate field fails its `ObjectStackDefinitionSchema` parse at load (`packages/metadata/src/plugin.ts` `_parseAndRegisterArtifact`, a throwing `.parse` before any registration, reached from `start`), so it is refused whole and must be rebuilt; a stored `sys_metadata` object row registers anyway with a `[metadata_spec_invalid]` line (`packages/objectql/src/registry.ts` `registerItem` → `validate` → `ObjectSchema.parse`, caught and logged) and its next save is refused `422 INVALID_METADATA` (`packages/metadata-protocol/src/protocol-save.ts`, the `object` schema is `ObjectSchema`). Stored data rows are unaffected. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P <!-- signed: domain:spec#2 · os-sales · session_01L6bhgVugtCCwBs98FeR27P --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47c457b commit b77c160

6 files changed

Lines changed: 194 additions & 13 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+
feat(spec)!: the object parse refuses a `homeAmount.rateField` whose number field declares a `scale` below 12, the decimals an exchange rate carries; `CURRENCY_FRACTION_DIGITS` is documented as CLDR fraction digits, not ISO 4217 minor units
6+
7+
Clause-②: no (narrowing) — the object parse (`ObjectSchema`, so `os validate`, `os compile` and the metadata doors that parse an object) refuses an object whose `homeAmount.rateField` names a `number` field declaring `scale` 0 to 11, a declaration it accepted before. Nothing is widened.
8+
Metadata-protocol: narrowed
9+
Skills-docs: none — `skills/objectstack-data/rules/field-types.md` teaches `rateField` as a readonly `number` with no `scale`, which this change still accepts; the floor ships in the refusal text, the FROM → TO below and the upgrade-guide entry.
10+
11+
<!-- adr-0087: registered home-amount-rate-field-scale-floor -->
12+
13+
**BREAKING** accept-set narrowing in the object parse: `fields.AMOUNT.currencyConfig.homeAmount.rateField`.
14+
15+
- **Before.** A rate field declared `Field.number({ readonly: true, scale: 4 })` parsed. The engine stored the rate it used with all twelve decimals anyway (the rate row's own value, never rounded), so the declared `scale` only rounded the rate a reader was shown: `0.1389` for a stored `0.138912345678`, a rate that no longer reproduced the stored home amount.
16+
- **Now.** The parse refuses it at `fields.AMOUNT.currencyConfig.homeAmount.rateField`, naming the field, its `scale` and the floor 12, the rate's scale (ruling 6469382727: twelve digits after the decimal point, total precision 24). A rate field with no `scale` is accepted: on a `number` field an absent `scale` declares no fixed width, so the rate shows every digit it was stored with. `scale: 12` or more is accepted.
17+
- **Unchanged.** What the engine stores and computes, and every stored data row (record values).
18+
- **Metadata already on disk.** A built artifact whose object declares such a rate field is refused whole at load (its `ObjectStackDefinitionSchema` parse fails before anything registers): fix the source and rebuild. A stored `sys_metadata` object row is kept as authored and still loads and registers; boot logs `[metadata_spec_invalid]` at `fields.AMOUNT.currencyConfig.homeAmount.rateField`, and its next save is refused `422 INVALID_METADATA` until the rate field's `scale` is raised to 12 or more, or deleted.
19+
- **Docs.** `CURRENCY_FRACTION_DIGITS` and the currency page now say the table is CLDR's fraction digits, and list the sixteen codes where they differ from ISO 4217's minor units: IQD (CLDR 0, ISO 3) and AFN, ALL, COP, HUF, IDR, IRR, KPW, LAK, LBP, MGA, MMK, PKR, SOS, SYP, YER (CLDR 0, ISO 2), measured against SIX's ISO 4217 List One of 2024-06-25.
20+
21+
**FROM → TO.** `amount_rate: Field.number({ readonly: true, scale: 4 })` → `amount_rate: Field.number({ readonly: true, scale: 12 })`, or `Field.number({ readonly: true })` with no `scale`.

‎content/docs/data-modeling/currency.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,10 +300,11 @@ document date, the engine computes the home amount:
300300
`date` document date is its own day.
301301
- **The home amount** is the amount **divided by** the rate, computed in exact decimal
302302
arithmetic and rounded once, half away from zero, to the home currency's decimal
303-
places as CLDR gives them (its fraction digits): 0 for JPY, 2 for USD and CNY, 3 for
304-
KWD. For a few currencies CLDR's count differs from ISO 4217's minor units (IQD, HUF,
305-
COP, IDR and PKR among them), and CLDR's applies. USD 10,000 at a rate of 0.1389 into
306-
CNY stores ¥71,994.24.
303+
places as CLDR gives them (its fraction digits, not ISO 4217's minor units): 0 for JPY,
304+
2 for USD and CNY, 3 for KWD. For sixteen currencies the two differ, and CLDR's count
305+
applies: IQD has 0 (ISO 4217: 3), and AFN, ALL, COP, HUF, IDR, IRR, KPW, LAK, LBP, MGA,
306+
MMK, PKR, SOS, SYP and YER have 0 (ISO 4217: 2), so a home amount in any of them is
307+
rounded to whole units. USD 10,000 at a rate of 0.1389 into CNY stores ¥71,994.24.
307308
- **The rate field** stores the rate row's own value, unrounded, so the stored home
308309
amount always reproduces from the stored amount and rate.
309310
- **An amount in the home currency itself** stores rate `1` and needs no rate row.

‎packages/spec/src/data/currency-config-home-amount.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { describe, it, expect } from 'vitest';
1717

1818
import { CurrencyConfigSchema } from './field.zod';
1919
import { ObjectSchema } from './object.zod';
20+
import { ObjectStackDefinitionSchema } from '../stack.zod';
2021

2122
const HOME = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' } as const;
2223
const RECORD = { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME } as const;
@@ -256,3 +257,57 @@ describe('the object judges what `homeAmount` names', () => {
256257
expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount` is declared on a `formula` field of object `invoice`, which computes its value on read/);
257258
});
258259
});
260+
261+
// #30103 — ruling 6469382727: an exchange rate carries scale 12. The engine
262+
// stores the rate row's own value in the rate field whatever `scale` the field
263+
// declares, so a declared `scale` below 12 only rounds the rate a reader sees.
264+
// Judged through the stack parse `os validate` and `os compile` run
265+
// (`ObjectStackDefinitionSchema.safeParse`), so the pins hold the door an
266+
// author actually meets, not only the object schema.
267+
describe('`homeAmount.rateField` declares no `scale` below the rate\'s twelve decimals', () => {
268+
const stackIssues = (rate: Record<string, unknown>): Array<{ code: string; path: string; message: string }> => {
269+
const r = ObjectStackDefinitionSchema.safeParse({ objects: [invoice({ amount_rate: { ...BASE_FIELDS.amount_rate, ...rate } })] });
270+
if (r.success) return [];
271+
return r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message }));
272+
};
273+
274+
it('refuses `scale: 4`, at the pointer, naming the field and the floor 12, with the prescription', () => {
275+
const issues = stackIssues({ scale: 4 });
276+
expect(issues).toHaveLength(1);
277+
expect(issues[0]!.code).toBe('custom');
278+
expect(issues[0]!.path).toBe(`objects.0.${AT}.rateField`);
279+
expect(issues[0]!.message).toMatch(
280+
/^`amount\.currencyConfig\.homeAmount\.rateField` names `amount_rate`, a `number` field declaring `scale: 4`, below the 12 decimal places an exchange rate carries\./,
281+
);
282+
expect(issues[0]!.message).toContain('Declare `scale: 12` (or more) on `amount_rate`, or delete its `scale`');
283+
// A runtime string carries no tracker number.
284+
expect(issues[0]!.message).not.toMatch(/#\d/);
285+
});
286+
287+
it('refuses every declared `scale` below 12 (0 and 11, the edges)', () => {
288+
for (const scale of [0, 11]) {
289+
const issues = stackIssues({ scale });
290+
expect(issues.map((i) => i.path), String(scale)).toEqual([`objects.0.${AT}.rateField`]);
291+
expect(issues[0]!.message).toContain(`declaring \`scale: ${scale}\``);
292+
}
293+
});
294+
295+
it.each([12, 14])('accepts `scale: %i` — the full rate shows, padded at most', (scale) => {
296+
expect(stackIssues({ scale })).toEqual([]);
297+
});
298+
299+
it('accepts an undeclared `scale`: a `number` field then declares no fixed width and shows every stored digit', () => {
300+
expect('scale' in BASE_FIELDS.amount_rate).toBe(false);
301+
expect(stackIssues({})).toEqual([]);
302+
});
303+
304+
it('judges the shared column once: two amounts storing one rate into a `scale: 4` field meet one issue', () => {
305+
const r = ObjectStackDefinitionSchema.safeParse({ objects: [invoice({
306+
amount_rate: { ...BASE_FIELDS.amount_rate, scale: 4 },
307+
tax: { type: 'currency', label: 'Tax', currencyConfig: { ...RECORD, homeAmount: { ...HOME, field: 'tax_home' } } },
308+
tax_home: { type: 'currency', label: 'Tax (home)', readonly: true },
309+
})] });
310+
expect(r.success).toBe(false);
311+
expect(r.error!.issues.map((i) => i.path.join('.'))).toEqual([`objects.0.${AT}.rateField`]);
312+
});
313+
});

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

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,36 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* ISO 4217 / CLDR currency fraction digits — a checked-in snapshot whose KEY
5-
* SET is the membership list of the `iso_4217_currency` value domain
6-
* (`shared/value-domain.zod.ts`): a `text` field declaring
4+
* CLDR currency fraction digits — NOT ISO 4217 minor units — a checked-in
5+
* snapshot whose KEY SET is the membership list of the `iso_4217_currency`
6+
* value domain (`shared/value-domain.zod.ts`): a `text` field declaring
77
* `valueDomain: 'iso_4217_currency'` accepts exactly these codes.
88
*
9+
* ## Where CLDR's digits differ from ISO 4217's minor units
10+
*
11+
* The digit VALUES are CLDR's `currencyData` counts: the decimal places a
12+
* currency is written with in practice. ISO 4217's `CcyMnrUnts` is the minor
13+
* unit the standard defines, and for sixteen codes of this table the two
14+
* differ — in every case CLDR records `0`, a minor unit that is defined but
15+
* no longer used:
16+
*
17+
* | ISO 4217 minor unit | Codes (CLDR digits: 0) |
18+
* |:---|:---|
19+
* | 3 | IQD |
20+
* | 2 | AFN, ALL, COP, HUF, IDR, IRR, KPW, LAK, LBP, MGA, MMK, PKR, SOS, SYP, YER |
21+
*
22+
* Measured, not recalled: every entry below compared with SIX's ISO 4217
23+
* List One as published 2024-06-25 (the `iso-4217-list-one.xml` that
24+
* `currency-codes@2.2.0` ships), cross-read against the `datasets/currency-codes`
25+
* `codes-all.csv` mirror of the same list (its active rows): both answer the
26+
* same sixteen codes. Two entries have no ISO minor unit to compare (`N.A.`:
27+
* XDR and XSU, here 2), and HRK, SLL and ZWL are withdrawn from the current
28+
* list, so no current ISO minor unit exists for them either. Every other code
29+
* here equals its ISO 4217 minor unit.
30+
*
31+
* A reader that rounds to these digits rounds to CLDR's count: an IQD amount
32+
* is rounded to whole dinars, not to fils.
33+
*
934
* #19992 — the snapshot was generated for the #7918 publish-time rule that
1035
* compared an authored `currencyConfig.precision` with the currency's fraction
1136
* digits. That key was removed (ADR-0049 enforce-or-remove: no renderer or
@@ -51,10 +76,13 @@
5176
*/
5277

5378
/**
54-
* Fraction digits per currency code — every code CLDR 48.0 `currencyData`
55-
* carries (162 entries; see the provenance block above). 0-digit currencies
56-
* (JPY, KRW, CLP, ISK, VND, …) have no minor unit at all; 3-digit currencies
57-
* (BHD, JOD, KWD, LYD, OMR, TND) have a thousandth minor unit (fils/baisa).
79+
* CLDR fraction digits per currency code — every code CLDR 48.0
80+
* `currencyData` carries (162 entries; see the provenance block above). They
81+
* are not ISO 4217 minor units: sixteen codes differ (IQD, and fifteen codes
82+
* CLDR writes with 0 digits where ISO 4217 defines 2 — the table above).
83+
* 0-digit currencies (JPY, KRW, CLP, ISK, VND, …) are written without
84+
* decimals; 3-digit currencies (BHD, JOD, KWD, LYD, OMR, TND) with a
85+
* thousandth (fils/baisa).
5886
*/
5987
export const CURRENCY_FRACTION_DIGITS: Readonly<Record<string, number>> = {
6088
AED: 2, AFN: 0, ALL: 0, AMD: 2, ANG: 2, AOA: 2, ARS: 2, AUD: 2,

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

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,6 +1948,26 @@ function refuseDishonestRecordCurrencyField(
19481948
/** The field types a `homeAmount.rateDate` pointer may name: a calendar date, with or without a time. */
19491949
const HOME_AMOUNT_RATE_DATE_TYPES: ReadonlySet<string> = new Set(['date', 'datetime']);
19501950

1951+
/**
1952+
* The decimal places an exchange rate carries: twelve digits after the point
1953+
* (ruling 6469382727, "scale 12 — twelve digits after the decimal
1954+
* point, total precision 24"). It is the `scale` platform-objects declares on
1955+
* `sys_exchange_rate.rate` (`EXCHANGE_RATE_SCALE`); the spec cannot import
1956+
* that package, so the number is restated here and the ruling is the one
1957+
* source both cite.
1958+
*
1959+
* The floor a `homeAmount.rateField` may declare. The engine stores the rate
1960+
* row's own value in the rate field, never rounded, and every numeric column
1961+
* is the same exact decimal, so a declared `scale` there constrains no write
1962+
* (a `readonly` value is judged for its shape only) and reaches the display
1963+
* alone: a rate field at `scale: 4` would show 0.1389 for a stored
1964+
* 0.138912345678, and the shown rate would no longer reproduce the stored
1965+
* home amount. An UNDECLARED `scale` is not refused: on a `number` field it
1966+
* declares no fixed width (`resolveFieldScale` answers `undefined`), so the
1967+
* rate shows the digits it was stored with, all twelve.
1968+
*/
1969+
const HOME_AMOUNT_RATE_SCALE = 12;
1970+
19511971
/**
19521972
* [ADR-0144 D5] A `record`-mode amount's `currencyConfig.homeAmount` names
19531973
* three fields THIS object declares: `field`, where the engine stores the
@@ -1976,7 +1996,9 @@ const HOME_AMOUNT_RATE_DATE_TYPES: ReadonlySet<string> = new Set(['date', 'datet
19761996
* - `rateDate` is not a declared `date` / `datetime` field;
19771997
* - `rateField` is not a declared `number` field, or is shared with an amount
19781998
* that names another code field or another document date (the two would
1979-
* store two different rates into one column);
1999+
* store two different rates into one column), or declares a `scale` below
2000+
* the rate's twelve decimals ({@link HOME_AMOUNT_RATE_SCALE}; an undeclared
2001+
* `scale` is accepted, since it shows every stored digit);
19802002
* - the home field or the rate field is not marked platform-written. The
19812003
* engine writes both, so each declares `readonly: true` — the existing mark
19822004
* that drops every non-system write of the field from the payload — and no
@@ -1996,7 +2018,7 @@ function refuseDishonestHomeAmount(
19962018
const objectName = typeof ownName === 'string' ? ownName : '<unnamed>';
19972019
type HomeAmount = { field?: unknown; rateDate?: unknown; rateField?: unknown };
19982020
type CurrencyConfigView = { currencyMode?: unknown; currencyField?: unknown; homeAmount?: HomeAmount };
1999-
type FieldView = { type?: unknown; readonly?: unknown; expression?: unknown; currencyConfig?: CurrencyConfigView } | null;
2021+
type FieldView = { type?: unknown; readonly?: unknown; expression?: unknown; scale?: unknown; currencyConfig?: CurrencyConfigView } | null;
20002022
const map = fields as Record<string, FieldView>;
20012023
const has = (name: string): boolean => Object.prototype.hasOwnProperty.call(map, name);
20022024
const modeOf = (f: FieldView): unknown => f?.currencyConfig?.currencyMode ?? 'dynamic';
@@ -2146,6 +2168,22 @@ function refuseDishonestHomeAmount(
21462168
// sharer stores the same rate into the same, already judged, column.
21472169
rateOwner.set(target, { amount: name, currencyField: config.currencyField, rateDate: home.rateDate });
21482170
refuseUnlessPlatformWritten('rateField', target, 'the rate field');
2171+
// The rate is stored at twelve decimals whatever the field declares, so
2172+
// a narrower `scale` only rounds the rate a reader is shown (ruling
2173+
// 6469382727). Judged once per column, like the mark above.
2174+
const scale = t?.scale;
2175+
if (typeof scale === 'number' && scale < HOME_AMOUNT_RATE_SCALE) {
2176+
ctx.addIssue({
2177+
code: 'custom',
2178+
path: [...base, 'rateField'],
2179+
message: `${at}.rateField\` names \`${target}\`, a \`number\` field declaring \`scale: ${scale}\`, below the `
2180+
+ `${HOME_AMOUNT_RATE_SCALE} decimal places an exchange rate carries. The engine stores the rate it used `
2181+
+ `with all ${HOME_AMOUNT_RATE_SCALE} decimals, so \`${target}\` would show it rounded to ${scale} and the `
2182+
+ 'shown rate would not reproduce the stored home amount. Declare '
2183+
+ `\`scale: ${HOME_AMOUNT_RATE_SCALE}\` (or more) on \`${target}\`, or delete its \`scale\` so the rate `
2184+
+ 'shows every digit it was stored with.',
2185+
});
2186+
}
21492187
}
21502188
}
21512189
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'home-amount-rate-field-scale-floor',
7+
surface:
8+
'object.fields.<amount>.currencyConfig.homeAmount.rateField on a record-currency amount: the number field it '
9+
+ 'names declaring a scale below 12 (scale 0 to 11). A rate field with no scale, or a scale of 12 or more, is '
10+
+ 'untouched',
11+
replacement:
12+
'Declare scale: 12 (or more) on the rate field, or delete its scale so the rate shows every digit it was '
13+
+ 'stored with. Nothing else changes: the field stays a readonly number field and keeps its name',
14+
reason:
15+
'An exchange rate carries twelve decimal places (ruling 6469382727: scale 12, total precision 24). The engine '
16+
+ 'stores the rate row\'s own value in the rate field, never rounded, and every numeric column is the same exact '
17+
+ 'decimal, so a scale declared on the rate field constrains no write and reaches the display only: a rate field '
18+
+ 'at scale 4 showed 0.1389 for a stored 0.138912345678, and the shown rate no longer reproduced the stored home '
19+
+ 'amount (10000 / 0.138912345678 = 71987.84; at 0.1389 it is 71994.24). The object parse now refuses it at '
20+
+ 'fields.AMOUNT.currencyConfig.homeAmount.rateField, naming the field, its scale and the floor 12. An undeclared '
21+
+ 'scale is accepted: on a number field it declares no fixed width, so the rate shows its stored digits. NOT '
22+
+ 'mechanically converted: raising the scale or deleting it are both right, and which one is the author\'s '
23+
+ 'display choice. OLDER BUILT ARTIFACTS: an artifact whose object declares such a rate field fails its '
24+
+ 'ObjectStackDefinitionSchema parse at load, before anything in it registers, so the whole artifact is refused '
25+
+ 'and the metadata plugin\'s start fails with the Zod issue at that path; rebuild the artifact after fixing the '
26+
+ 'source. STORED sys_metadata OBJECT ROWS are kept as authored and nothing converts one: such a row still loads '
27+
+ 'and registers unchanged, boot logs it with a [metadata_spec_invalid] line at '
28+
+ 'fields.AMOUNT.currencyConfig.homeAmount.rateField, and its next save is refused with 422 INVALID_METADATA '
29+
+ 'until the scale is raised to 12 or more or deleted. Stored DATA rows (record values) are unaffected; the '
30+
+ 'engine already kept every digit of the rate.',
31+
acceptanceCriteria:
32+
'objectstack validate (ObjectStackDefinitionSchema.safeParse) reports no issue at '
33+
+ 'fields.AMOUNT.currencyConfig.homeAmount.rateField for any amount declaring homeAmount, and the rate field '
34+
+ 'either declares no scale or declares scale 12 or more; a record written afterwards shows its stored rate with '
35+
+ 'all of its decimals. Rebuild every built artifact after the fix. For stored sys_metadata object rows, look '
36+
+ 'for the [metadata_spec_invalid] boot line at fields.AMOUNT.currencyConfig.homeAmount.rateField: raise that '
37+
+ 'rate field\'s scale to 12 or more, or delete it, and save the object.',
38+
};

0 commit comments

Comments
 (0)