From 9feaa117c83165d80cd4e4a7080ccf9a49c12b71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 18:17:24 +0000 Subject: [PATCH 1/3] feat(spec): refuse a homeAmount rateField declaring scale below 12; document CURRENCY_FRACTION_DIGITS as CLDR fraction digits Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P --- content/docs/data-modeling/currency.mdx | 9 +-- .../data/currency-config-home-amount.test.ts | 55 +++++++++++++++++++ .../spec/src/data/currency-fraction-digits.ts | 42 +++++++++++--- packages/spec/src/data/object.zod.ts | 42 +++++++++++++- 4 files changed, 135 insertions(+), 13 deletions(-) diff --git a/content/docs/data-modeling/currency.mdx b/content/docs/data-modeling/currency.mdx index 4c39d34c694..f2e67d1f56f 100644 --- a/content/docs/data-modeling/currency.mdx +++ b/content/docs/data-modeling/currency.mdx @@ -300,10 +300,11 @@ document date, the engine computes the home amount: `date` document date is its own day. - **The home amount** is the amount **divided by** the rate, computed in exact decimal arithmetic and rounded once, half away from zero, to the home currency's decimal - places as CLDR gives them (its fraction digits): 0 for JPY, 2 for USD and CNY, 3 for - KWD. For a few currencies CLDR's count differs from ISO 4217's minor units (IQD, HUF, - COP, IDR and PKR among them), and CLDR's applies. USD 10,000 at a rate of 0.1389 into - CNY stores ¥71,994.24. + places as CLDR gives them (its fraction digits, not ISO 4217's minor units): 0 for JPY, + 2 for USD and CNY, 3 for KWD. For sixteen currencies the two differ, and CLDR's count + applies: IQD has 0 (ISO 4217: 3), and AFN, ALL, COP, HUF, IDR, IRR, KPW, LAK, LBP, MGA, + MMK, PKR, SOS, SYP and YER have 0 (ISO 4217: 2), so a home amount in any of them is + rounded to whole units. USD 10,000 at a rate of 0.1389 into CNY stores ¥71,994.24. - **The rate field** stores the rate row's own value, unrounded, so the stored home amount always reproduces from the stored amount and rate. - **An amount in the home currency itself** stores rate `1` and needs no rate row. diff --git a/packages/spec/src/data/currency-config-home-amount.test.ts b/packages/spec/src/data/currency-config-home-amount.test.ts index ef89f672799..1c7012c6a9a 100644 --- a/packages/spec/src/data/currency-config-home-amount.test.ts +++ b/packages/spec/src/data/currency-config-home-amount.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect } from 'vitest'; import { CurrencyConfigSchema } from './field.zod'; import { ObjectSchema } from './object.zod'; +import { ObjectStackDefinitionSchema } from '../stack.zod'; const HOME = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' } as const; const RECORD = { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME } as const; @@ -256,3 +257,57 @@ describe('the object judges what `homeAmount` names', () => { expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount` is declared on a `formula` field of object `invoice`, which computes its value on read/); }); }); + +// #30103 — ruling 6469382727: an exchange rate carries scale 12. The engine +// stores the rate row's own value in the rate field whatever `scale` the field +// declares, so a declared `scale` below 12 only rounds the rate a reader sees. +// Judged through the stack parse `os validate` and `os compile` run +// (`ObjectStackDefinitionSchema.safeParse`), so the pins hold the door an +// author actually meets, not only the object schema. +describe('`homeAmount.rateField` declares no `scale` below the rate\'s twelve decimals', () => { + const stackIssues = (rate: Record): Array<{ code: string; path: string; message: string }> => { + const r = ObjectStackDefinitionSchema.safeParse({ objects: [invoice({ amount_rate: { ...BASE_FIELDS.amount_rate, ...rate } })] }); + if (r.success) return []; + return r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); + }; + + it('refuses `scale: 4`, at the pointer, naming the field and the floor 12, with the prescription', () => { + const issues = stackIssues({ scale: 4 }); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toBe(`objects.0.${AT}.rateField`); + expect(issues[0]!.message).toMatch( + /^`amount\.currencyConfig\.homeAmount\.rateField` names `amount_rate`, a `number` field declaring `scale: 4`, below the 12 decimal places an exchange rate carries\./, + ); + expect(issues[0]!.message).toContain('Declare `scale: 12` (or more) on `amount_rate`, or delete its `scale`'); + // A runtime string carries no tracker number. + expect(issues[0]!.message).not.toMatch(/#\d/); + }); + + it('refuses every declared `scale` below 12 (0 and 11, the edges)', () => { + for (const scale of [0, 11]) { + const issues = stackIssues({ scale }); + expect(issues.map((i) => i.path), String(scale)).toEqual([`objects.0.${AT}.rateField`]); + expect(issues[0]!.message).toContain(`declaring \`scale: ${scale}\``); + } + }); + + it.each([12, 14])('accepts `scale: %i` — the full rate shows, padded at most', (scale) => { + expect(stackIssues({ scale })).toEqual([]); + }); + + it('accepts an undeclared `scale`: a `number` field then declares no fixed width and shows every stored digit', () => { + expect('scale' in BASE_FIELDS.amount_rate).toBe(false); + expect(stackIssues({})).toEqual([]); + }); + + it('judges the shared column once: two amounts storing one rate into a `scale: 4` field meet one issue', () => { + const r = ObjectStackDefinitionSchema.safeParse({ objects: [invoice({ + amount_rate: { ...BASE_FIELDS.amount_rate, scale: 4 }, + tax: { type: 'currency', label: 'Tax', currencyConfig: { ...RECORD, homeAmount: { ...HOME, field: 'tax_home' } } }, + tax_home: { type: 'currency', label: 'Tax (home)', readonly: true }, + })] }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.path.join('.'))).toEqual([`objects.0.${AT}.rateField`]); + }); +}); diff --git a/packages/spec/src/data/currency-fraction-digits.ts b/packages/spec/src/data/currency-fraction-digits.ts index d5d6f9aef64..460ac12834e 100644 --- a/packages/spec/src/data/currency-fraction-digits.ts +++ b/packages/spec/src/data/currency-fraction-digits.ts @@ -1,11 +1,36 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * ISO 4217 / CLDR currency fraction digits — a checked-in snapshot whose KEY - * SET is the membership list of the `iso_4217_currency` value domain - * (`shared/value-domain.zod.ts`): a `text` field declaring + * CLDR currency fraction digits — NOT ISO 4217 minor units — a checked-in + * snapshot whose KEY SET is the membership list of the `iso_4217_currency` + * value domain (`shared/value-domain.zod.ts`): a `text` field declaring * `valueDomain: 'iso_4217_currency'` accepts exactly these codes. * + * ## Where CLDR's digits differ from ISO 4217's minor units + * + * The digit VALUES are CLDR's `currencyData` counts: the decimal places a + * currency is written with in practice. ISO 4217's `CcyMnrUnts` is the minor + * unit the standard defines, and for sixteen codes of this table the two + * differ — in every case CLDR records `0`, a minor unit that is defined but + * no longer used: + * + * | ISO 4217 minor unit | Codes (CLDR digits: 0) | + * |:---|:---| + * | 3 | IQD | + * | 2 | AFN, ALL, COP, HUF, IDR, IRR, KPW, LAK, LBP, MGA, MMK, PKR, SOS, SYP, YER | + * + * Measured, not recalled: every entry below compared with SIX's ISO 4217 + * List One as published 2024-06-25 (the `iso-4217-list-one.xml` that + * `currency-codes@2.2.0` ships), cross-read against the `datasets/currency-codes` + * `codes-all.csv` mirror of the same list (its active rows): both answer the + * same sixteen codes. Two entries have no ISO minor unit to compare (`N.A.`: + * XDR and XSU, here 2), and HRK, SLL and ZWL are withdrawn from the current + * list, so no current ISO minor unit exists for them either. Every other code + * here equals its ISO 4217 minor unit. + * + * A reader that rounds to these digits rounds to CLDR's count: an IQD amount + * is rounded to whole dinars, not to fils. + * * #19992 — the snapshot was generated for the #7918 publish-time rule that * compared an authored `currencyConfig.precision` with the currency's fraction * digits. That key was removed (ADR-0049 enforce-or-remove: no renderer or @@ -51,10 +76,13 @@ */ /** - * Fraction digits per currency code — every code CLDR 48.0 `currencyData` - * carries (162 entries; see the provenance block above). 0-digit currencies - * (JPY, KRW, CLP, ISK, VND, …) have no minor unit at all; 3-digit currencies - * (BHD, JOD, KWD, LYD, OMR, TND) have a thousandth minor unit (fils/baisa). + * CLDR fraction digits per currency code — every code CLDR 48.0 + * `currencyData` carries (162 entries; see the provenance block above). They + * are not ISO 4217 minor units: sixteen codes differ (IQD, and fifteen codes + * CLDR writes with 0 digits where ISO 4217 defines 2 — the table above). + * 0-digit currencies (JPY, KRW, CLP, ISK, VND, …) are written without + * decimals; 3-digit currencies (BHD, JOD, KWD, LYD, OMR, TND) with a + * thousandth (fils/baisa). */ export const CURRENCY_FRACTION_DIGITS: Readonly> = { AED: 2, AFN: 0, ALL: 0, AMD: 2, ANG: 2, AOA: 2, ARS: 2, AUD: 2, diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 06e35bf52d0..7f70cb81172 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1948,6 +1948,26 @@ function refuseDishonestRecordCurrencyField( /** The field types a `homeAmount.rateDate` pointer may name: a calendar date, with or without a time. */ const HOME_AMOUNT_RATE_DATE_TYPES: ReadonlySet = new Set(['date', 'datetime']); +/** + * The decimal places an exchange rate carries: twelve digits after the point + * (ruling 6469382727, "scale 12 — twelve digits after the decimal + * point, total precision 24"). It is the `scale` platform-objects declares on + * `sys_exchange_rate.rate` (`EXCHANGE_RATE_SCALE`); the spec cannot import + * that package, so the number is restated here and the ruling is the one + * source both cite. + * + * The floor a `homeAmount.rateField` may declare. The engine stores the rate + * row's own value in the rate field, never rounded, and every numeric column + * is the same exact decimal, so a declared `scale` there constrains no write + * (a `readonly` value is judged for its shape only) and reaches the display + * alone: a rate field at `scale: 4` would show 0.1389 for a stored + * 0.138912345678, and the shown rate would no longer reproduce the stored + * home amount. An UNDECLARED `scale` is not refused: on a `number` field it + * declares no fixed width (`resolveFieldScale` answers `undefined`), so the + * rate shows the digits it was stored with, all twelve. + */ +const HOME_AMOUNT_RATE_SCALE = 12; + /** * [ADR-0144 D5] A `record`-mode amount's `currencyConfig.homeAmount` names * three fields THIS object declares: `field`, where the engine stores the @@ -1976,7 +1996,9 @@ const HOME_AMOUNT_RATE_DATE_TYPES: ReadonlySet = new Set(['date', 'datet * - `rateDate` is not a declared `date` / `datetime` field; * - `rateField` is not a declared `number` field, or is shared with an amount * that names another code field or another document date (the two would - * store two different rates into one column); + * store two different rates into one column), or declares a `scale` below + * the rate's twelve decimals ({@link HOME_AMOUNT_RATE_SCALE}; an undeclared + * `scale` is accepted, since it shows every stored digit); * - the home field or the rate field is not marked platform-written. The * engine writes both, so each declares `readonly: true` — the existing mark * that drops every non-system write of the field from the payload — and no @@ -1996,7 +2018,7 @@ function refuseDishonestHomeAmount( const objectName = typeof ownName === 'string' ? ownName : ''; type HomeAmount = { field?: unknown; rateDate?: unknown; rateField?: unknown }; type CurrencyConfigView = { currencyMode?: unknown; currencyField?: unknown; homeAmount?: HomeAmount }; - type FieldView = { type?: unknown; readonly?: unknown; expression?: unknown; currencyConfig?: CurrencyConfigView } | null; + type FieldView = { type?: unknown; readonly?: unknown; expression?: unknown; scale?: unknown; currencyConfig?: CurrencyConfigView } | null; const map = fields as Record; const has = (name: string): boolean => Object.prototype.hasOwnProperty.call(map, name); const modeOf = (f: FieldView): unknown => f?.currencyConfig?.currencyMode ?? 'dynamic'; @@ -2146,6 +2168,22 @@ function refuseDishonestHomeAmount( // sharer stores the same rate into the same, already judged, column. rateOwner.set(target, { amount: name, currencyField: config.currencyField, rateDate: home.rateDate }); refuseUnlessPlatformWritten('rateField', target, 'the rate field'); + // The rate is stored at twelve decimals whatever the field declares, so + // a narrower `scale` only rounds the rate a reader is shown (ruling + // 6469382727). Judged once per column, like the mark above. + const scale = t?.scale; + if (typeof scale === 'number' && scale < HOME_AMOUNT_RATE_SCALE) { + ctx.addIssue({ + code: 'custom', + path: [...base, 'rateField'], + message: `${at}.rateField\` names \`${target}\`, a \`number\` field declaring \`scale: ${scale}\`, below the ` + + `${HOME_AMOUNT_RATE_SCALE} decimal places an exchange rate carries. The engine stores the rate it used ` + + `with all ${HOME_AMOUNT_RATE_SCALE} decimals, so \`${target}\` would show it rounded to ${scale} and the ` + + 'shown rate would not reproduce the stored home amount. Declare ' + + `\`scale: ${HOME_AMOUNT_RATE_SCALE}\` (or more) on \`${target}\`, or delete its \`scale\` so the rate ` + + 'shows every digit it was stored with.', + }); + } } } } From 08d3f67c474a7b3249f2d810d1d936ba15ba8207 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 18:18:39 +0000 Subject: [PATCH 2/3] chore(spec): changeset and ADR-0087 semantic entry for the homeAmount rateField scale floor Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P --- ...0103-home-amount-rate-field-scale-floor.md | 20 +++++++++++++ .../18.home-amount-rate-field-scale-floor.ts | 29 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 .changeset/30103-home-amount-rate-field-scale-floor.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.home-amount-rate-field-scale-floor.ts diff --git a/.changeset/30103-home-amount-rate-field-scale-floor.md b/.changeset/30103-home-amount-rate-field-scale-floor.md new file mode 100644 index 00000000000..4339a3156e8 --- /dev/null +++ b/.changeset/30103-home-amount-rate-field-scale-floor.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': minor +--- + +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 + +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. +Metadata-protocol: narrowed +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. + + + +**BREAKING** accept-set narrowing in the object parse: `fields.AMOUNT.currencyConfig.homeAmount.rateField`. + +- **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. +- **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. +- **Unchanged.** What the engine stores and computes, and every stored row. +- **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. + +**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`. diff --git a/packages/spec/src/migrations/entries/semantic/18.home-amount-rate-field-scale-floor.ts b/packages/spec/src/migrations/entries/semantic/18.home-amount-rate-field-scale-floor.ts new file mode 100644 index 00000000000..44b2090c51f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.home-amount-rate-field-scale-floor.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'home-amount-rate-field-scale-floor', + surface: + 'object.fields..currencyConfig.homeAmount.rateField on a record-currency amount: the number field it ' + + 'names declaring a scale below 12 (scale 0 to 11). A rate field with no scale, or a scale of 12 or more, is ' + + 'untouched', + replacement: + 'Declare scale: 12 (or more) on the rate field, or delete its scale so the rate shows every digit it was ' + + 'stored with. Nothing else changes: the field stays a readonly number field and keeps its name', + reason: + 'An exchange rate carries twelve decimal places (ruling 6469382727: scale 12, total precision 24). The engine ' + + 'stores the rate row\'s own value in the rate field, never rounded, and every numeric column is the same exact ' + + 'decimal, so a scale declared on the rate field constrains no write and reaches the display only: a rate field ' + + 'at scale 4 showed 0.1389 for a stored 0.138912345678, and the shown rate no longer reproduced the stored home ' + + 'amount (10000 / 0.138912345678 = 71987.84; at 0.1389 it is 71994.24). The object parse now refuses it at ' + + 'fields.AMOUNT.currencyConfig.homeAmount.rateField, naming the field, its scale and the floor 12. An undeclared ' + + 'scale is accepted: on a number field it declares no fixed width, so the rate shows its stored digits. NOT ' + + 'mechanically converted: raising the scale or deleting it are both right, and which one is the author\'s ' + + 'display choice. Stored rows are unaffected; the engine already kept every digit.', + acceptanceCriteria: + 'objectstack validate (ObjectStackDefinitionSchema.safeParse) reports no issue at ' + + 'fields.AMOUNT.currencyConfig.homeAmount.rateField for any amount declaring homeAmount, and the rate field ' + + 'either declares no scale or declares scale 12 or more; a record written afterwards shows its stored rate with ' + + 'all of its decimals.', +}; From fcb86f79a42b967c781e8ae92ab9c4e17eeb8c3e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 20:33:02 +0000 Subject: [PATCH 3/3] docs(spec): name the built-artifact and stored-object-row legs in the rateField scale floor's upgrade entry Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P --- .../30103-home-amount-rate-field-scale-floor.md | 3 ++- .../18.home-amount-rate-field-scale-floor.ts | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.changeset/30103-home-amount-rate-field-scale-floor.md b/.changeset/30103-home-amount-rate-field-scale-floor.md index 4339a3156e8..772b5b74f3d 100644 --- a/.changeset/30103-home-amount-rate-field-scale-floor.md +++ b/.changeset/30103-home-amount-rate-field-scale-floor.md @@ -14,7 +14,8 @@ Skills-docs: none — `skills/objectstack-data/rules/field-types.md` teaches `ra - **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. - **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. -- **Unchanged.** What the engine stores and computes, and every stored row. +- **Unchanged.** What the engine stores and computes, and every stored data row (record values). +- **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. - **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. **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`. diff --git a/packages/spec/src/migrations/entries/semantic/18.home-amount-rate-field-scale-floor.ts b/packages/spec/src/migrations/entries/semantic/18.home-amount-rate-field-scale-floor.ts index 44b2090c51f..e68c5619e7b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.home-amount-rate-field-scale-floor.ts +++ b/packages/spec/src/migrations/entries/semantic/18.home-amount-rate-field-scale-floor.ts @@ -20,10 +20,19 @@ export const entry: SemanticMigration = { + 'fields.AMOUNT.currencyConfig.homeAmount.rateField, naming the field, its scale and the floor 12. An undeclared ' + 'scale is accepted: on a number field it declares no fixed width, so the rate shows its stored digits. NOT ' + 'mechanically converted: raising the scale or deleting it are both right, and which one is the author\'s ' - + 'display choice. Stored rows are unaffected; the engine already kept every digit.', + + 'display choice. OLDER BUILT ARTIFACTS: an artifact whose object declares such a rate field fails its ' + + 'ObjectStackDefinitionSchema parse at load, before anything in it registers, so the whole artifact is refused ' + + 'and the metadata plugin\'s start fails with the Zod issue at that path; rebuild the artifact after fixing the ' + + 'source. STORED sys_metadata OBJECT ROWS are kept as authored and nothing converts one: such a row still loads ' + + 'and registers unchanged, boot logs it with a [metadata_spec_invalid] line at ' + + 'fields.AMOUNT.currencyConfig.homeAmount.rateField, and its next save is refused with 422 INVALID_METADATA ' + + 'until the scale is raised to 12 or more or deleted. Stored DATA rows (record values) are unaffected; the ' + + 'engine already kept every digit of the rate.', acceptanceCriteria: 'objectstack validate (ObjectStackDefinitionSchema.safeParse) reports no issue at ' + 'fields.AMOUNT.currencyConfig.homeAmount.rateField for any amount declaring homeAmount, and the rate field ' + 'either declares no scale or declares scale 12 or more; a record written afterwards shows its stored rate with ' - + 'all of its decimals.', + + 'all of its decimals. Rebuild every built artifact after the fix. For stored sys_metadata object rows, look ' + + 'for the [metadata_spec_invalid] boot line at fields.AMOUNT.currencyConfig.homeAmount.rateField: raise that ' + + 'rate field\'s scale to 12 or more, or delete it, and save the object.', };