diff --git a/.changeset/19992-currency-config-precision-retired.md b/.changeset/19992-currency-config-precision-retired.md new file mode 100644 index 00000000000..35588890cff --- /dev/null +++ b/.changeset/19992-currency-config-precision-retired.md @@ -0,0 +1,78 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': patch +--- + +**BREAKING** — retire `currencyConfig.precision`: a currency's decimal places are its currency's (#19992). + +`currencyConfig.precision` was declared, validated against ISO 4217, and baked to `2` +into parse output — and **no renderer or runtime ever read it**. objectui's +`CurrencyField` derives an amount's decimal places from the currency's ISO 4217 +minor unit (2 for USD, 0 for JPY, 3 for KWD) and never looked at the key, so an +author who wrote `precision: 4` saw the same two decimals as everyone else. Its +only reader was its own contradiction check. ADR-0049 enforce-or-remove; triage +direction REMOVE under ruling 乙 on #19910 — 「a currency's decimal places are the +currency's, not a setting」. + +Clause-②: no + +## FROM → TO + +| you wrote (17.4 and earlier) | write instead | +| --- | --- | +| `currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }` | `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }` | +| `currencyConfig.decimals` / `currencyConfig.scale` (always refused, with a suggestion to write `precision`) | nothing — delete the key; the refusal now says why instead of suggesting `precision` | +| a field whose amounts need a different number of decimals | a different currency: the width is the currency's minor unit and is declared nowhere | + +**The one-line fix:** delete `precision` from every `currencyConfig`. ⛔ Do not move +the number to the field-level `precision`: that key is the amount's TOTAL digit count +(a DECIMAL(18,2) amount declares `precision: 18`), not its decimal places, and it is +unchanged by this release. + +`os migrate meta --from 17` lists the mechanical edits for existing sources; apply +them by hand. + +## The retirement kit + +- **`CurrencyConfigSchema.precision`** — removed from the shape. The schema is a + `strictObject`, so the route is strict deletion plus a `guidance` entry: an + authored key is refused as `unrecognized_keys` at `currencyConfig`, and the message + carries the prescription (``currencyConfig.precision` was removed in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer or runtime ever + read it: …``). `tsc` refuses a literal in a typed position too — the key is off + `CurrencyConfig`'s input type. +- **The `decimals` / `scale` aliases** — gone with their target. Each is now answered + with the same reason (`` `currencyConfig.scale` is not a currency configuration key, + and nothing replaces it: … ``) and no rename suggestion. +- **The ISO 4217 contradiction check** (the `.superRefine`) and **the + default-materializing `.overwrite()`** — both existed only for this key and are + removed. `CurrencyConfigSchema.parse({})` now returns exactly + `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`; `CurrencyConfigParsed` no + longer declares `precision`. The internal helpers `currencyPrecisionContradiction` + and `currencyFractionDigits` (never exported from a public entry) are removed; the + CLDR table they read stays, because the `iso_4217_currency` value domain reads its + key set. +- **The field designer form** — the field-level `precision` row's help text read + "Decimal places (e.g., 2 for $10.50)", the one reading the contract refuses. It now + reads "Total digits", matching the key's describe and the object designer's row; + the zh-CN / ja-JP / es-ES translations follow (`@objectstack/platform-objects`). +- **Registry** — `RETIRED_KEYS_BY_MAJOR[18]` gains `data/CurrencyConfig:precision`; + the protocol-18 step gains the D2 conversion `currency-config-precision-removed` and + its D3 entry `currency-config-precision-retired`, which states the two judgments the + strip cannot make: a width declared where the old check never looked (a `dynamic` + field, or a code with no known ISO 4217 minor unit) never applied, and code of your + own that read the served key must derive the width from the field's currency. + +## What an operator with STORED metadata sees + +Nearly every stored currency field carries this key without anyone having written it: +the old `.overwrite()` baked `precision: 2` into parse output, so `sys_metadata` +object rows and built artifacts hold it. Nothing breaks at read: the conversion +`currency-config-precision-removed` is retired from the load path but replayed by the +stored-row and artifact seams, which strip the key from every field's +`currencyConfig` on objects and object extensions and serve the row canonical. The +strip is lossless — the key never had an effect — and the field-level `precision` is +never touched. `os migrate meta --stored --apply` rewrites the stored rows so the +per-row notice stops. + + diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 7a71feb2f1c..5074fd98c81 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -152,33 +152,21 @@ code lives in `currencyConfig`, never in the value. | `precision` | `number` | — | Total digits | | `min` | `number` | — | Minimum value | | `max` | `number` | — | Maximum value | -| `currencyConfig.precision` | `number` | `2` | Decimal precision for currency | | `currencyConfig.currencyMode` | `'dynamic' \| 'fixed'` | `'dynamic'` | Whether the column is pinned to `defaultCurrency` (`fixed`) or displays in the tenant default currency, the `localization.currency` setting (`dynamic`); the value is a bare number either way | | `currencyConfig.defaultCurrency` | `string` | `'CNY'` | The column's currency code under `fixed`; not the displayed currency under `dynamic` | ```typescript -{ name: 'price', label: 'Price', type: 'currency', currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' } } +{ name: 'price', label: 'Price', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } } ``` 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. - -A declared `currencyConfig.precision` must agree with the currency's ISO 4217 -fraction digits when the currency is statically known — that is, when -`currencyConfig` is in `fixed` mode. Publish-time validation rejects the -contradiction, naming both numbers ("currency JPY has 0 fraction digits; -`precision: 2` contradicts it"): the yen has no minor unit to show two digits -of, and `precision: 2` on a KWD field would silently drop the third fils digit -that exists. Omit `currencyConfig.precision` to let renderers derive the width -from the currency itself. The rule is -deliberately partial: a field in `dynamic` currencyMode has no single currency -to check against, and currency codes outside CLDR `currencyData` -(cryptocurrency or custom business codes) are not checked. The rule judges -only *authored* precision — an untouched `currencyConfig` (whose `precision` -defaults to `2`) is never rejected, whatever its currency. +(2 for USD, 0 for JPY, 3 for KWD), not a field setting, and they are declared +nowhere. A `currency` field takes no `scale`, and `currencyConfig` has no +decimal-places key: publish-time validation refuses `scale` on the field and +`currencyConfig.precision` (removed in `@objectstack/spec` 17.5.0 — no renderer +or runtime ever read it), each with a prescription to delete 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. ### `percent` Percentage value (0–100 or decimal). diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index ffc9bf71e27..c12d4260484 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -74,7 +74,6 @@ discount: Field.percent({ label: 'Discount', scale: 2, min: 0, max: 1 }), price: Field.currency({ label: 'Price', currencyConfig: { - precision: 2, currencyMode: 'fixed', // 'fixed' pins the column to defaultCurrency; // 'dynamic' (default) shows the tenant default defaultCurrency: 'USD', // the column's currency under 'fixed' diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index 4762d9d821b..4b401348e17 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -152,11 +152,10 @@ These properties apply to **all** field types and are validated by the base `Fie |:---|:---|:---|:---| | `min` | `number` | — | Minimum monetary value | | `max` | `number` | — | Maximum monetary value | -| `currencyConfig.precision` | `number` | `2` | Decimal places (0–10) | -| `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 | +| `currencyConfig.currencyMode` | `enum` | `dynamic` | `fixed` pins the field to `defaultCurrency`; `dynamic` displays the tenant default currency (the `localization.currency` setting). Not a per-record choice | | `currencyConfig.defaultCurrency` | `string` | `CNY` | 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`; not the displayed currency under `dynamic` | -**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. +**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. Decimal places are the currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) and are declared nowhere — `currencyConfig` has no decimal-places key (`currencyConfig.precision` is refused, with a prescription to delete it), and the field-level `precision` is the total digit count, never compared with the currency. ### `percent` @@ -530,7 +529,7 @@ section above). See the | `html` | — | Sanitized, `maxLength` | | `richtext` | — | Sanitized, `maxLength` | | `number` | — | `min`, `max`, `precision`, `scale` | -| `currency` | — | `currencyConfig` (precision, mode, code) | +| `currency` | — | `currencyConfig` (mode, code) | | `percent` | — | `min`, `max`, stored as decimal | | `date` | — | ISO 8601 date | | `datetime` | — | ISO 8601 datetime, UTC | diff --git a/content/docs/getting-started/common-patterns.mdx b/content/docs/getting-started/common-patterns.mdx index bd6bad0058d..08a5fe15c3c 100644 --- a/content/docs/getting-started/common-patterns.mdx +++ b/content/docs/getting-started/common-patterns.mdx @@ -51,7 +51,7 @@ export default defineStack({ // Person references target the platform user object `sys_user` owner: { label: 'Owner', type: 'lookup', reference: 'sys_user' }, due_date: { label: 'Due Date', type: 'date' }, - budget: { label: 'Budget', type: 'currency', currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' } }, + budget: { label: 'Budget', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }, } } ] @@ -80,7 +80,7 @@ export default defineStack({ // Person references target the platform user object `sys_user` owner: { label: 'Owner', type: 'lookup', reference: 'sys_user' }, due_date: { label: 'Due Date', type: 'date' }, - budget: { label: 'Budget', type: 'currency', currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' } }, + budget: { label: 'Budget', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }, }, }, }, diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index 7c6b9c068e2..df658e50cdd 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -293,7 +293,6 @@ annual_revenue: type: currency label: Annual Revenue currencyConfig: - precision: 2 currencyMode: fixed defaultCurrency: USD # the field's currency under fixed ``` diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 51aae976e08..a730dd7662f 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -27,7 +27,6 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **precision** | `integer` | optional (default: `2`) | Decimal precision (default: 2) | | **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. | | **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | @@ -98,7 +97,7 @@ const result = CurrencyConfigSchema.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -235,7 +234,6 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **precision** | `integer` | optional (default: `2`) | Decimal precision (default: 2) | | **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. | | **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 8d68ea931e7..7a6a423eaea 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -260,7 +260,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -592,7 +592,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/content/docs/references/shared/value-domain.mdx b/content/docs/references/shared/value-domain.mdx index 5d7ae56507c..21772c256ed 100644 --- a/content/docs/references/shared/value-domain.mdx +++ b/content/docs/references/shared/value-domain.mdx @@ -26,8 +26,7 @@ shape — nothing consuming it moved). Prime Directive #2 keeps business logic out of the spec, and the earlier TSDoc of this vocabulary read that as "the list does not live here". The ruling above settles it the other way for this one predicate, on the same -footing as the package's existing shared verdicts: `currencyPrecisionContradiction` -(a checked-in CLDR table and the rule read over it), `filterVerdict`, the +footing as the package's existing shared verdicts: `filterVerdict`, the comparand-shape door. Each is a pure, dependency-free function two or more doors must answer IDENTICALLY — and "the same answer on both doors" is exactly what a shared contract is for. The predicate takes no I/O, holds no diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 9047ffb7e48..3f3b3486191 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -98,7 +98,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -518,7 +518,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/examples/app-showcase/src/data/objects/account.object.ts b/examples/app-showcase/src/data/objects/account.object.ts index 700705a8c03..58a9af7e247 100644 --- a/examples/app-showcase/src/data/objects/account.object.ts +++ b/examples/app-showcase/src/data/objects/account.object.ts @@ -68,7 +68,7 @@ export const Account = ObjectSchema.create({ annual_revenue: Field.currency({ label: 'Annual Revenue', min: 0, - currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, }), website: Field.url({ label: 'Website' }), hq: Field.location({ label: 'Headquarters' }), diff --git a/examples/app-showcase/src/data/objects/field-zoo.object.ts b/examples/app-showcase/src/data/objects/field-zoo.object.ts index 3fa545c3adc..4130858b8f8 100644 --- a/examples/app-showcase/src/data/objects/field-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/field-zoo.object.ts @@ -50,7 +50,7 @@ export const FieldZoo = ObjectSchema.create({ // ── Numbers ────────────────────────────────────────────────────────── f_number: Field.number({ label: 'Number', min: 0, max: 1000 }), - f_currency: Field.currency({ label: 'Currency', min: 0, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD', precision: 2 } }), + f_currency: Field.currency({ label: 'Currency', min: 0, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }), f_percent: Field.percent({ label: 'Percent', min: 0, max: 100, defaultValue: 50 }), // ── Date & time ────────────────────────────────────────────────────── diff --git a/examples/app-showcase/src/data/objects/semantic-zoo.object.ts b/examples/app-showcase/src/data/objects/semantic-zoo.object.ts index a165cfc9d20..97f0d4c22ff 100644 --- a/examples/app-showcase/src/data/objects/semantic-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/semantic-zoo.object.ts @@ -57,7 +57,7 @@ export const SemanticZoo = ObjectSchema.create({ // guessed symbol. budget: Field.currency({ label: 'Budget', - currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, group: 'money', }), notes: Field.textarea({ label: 'Notes' }), diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index f867b693420..d4136d03300 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -438,7 +438,7 @@ export const enMetadataForms: NonNullable = { }, precision: { label: "Precision", - helpText: "Decimal places (e.g., 2 for $10.50)" + helpText: "Total digits" }, scale: { label: "Scale", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 2cea4709f35..e0fe5716c52 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -438,7 +438,7 @@ export const esESMetadataForms: NonNullable = }, precision: { label: "Precisión", - helpText: "Decimales (p. ej., 2 para $10.50)" + helpText: "Total de dígitos" }, scale: { label: "Decimales", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index a5538f580d4..1eb07c325d6 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -438,7 +438,7 @@ export const jaJPMetadataForms: NonNullable = }, precision: { label: "精度", - helpText: "小数桁数(例: $10.50 なら 2)" + helpText: "総桁数" }, scale: { label: "小数桁", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index d244098baf3..fe4056800cf 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -438,7 +438,7 @@ export const zhCNMetadataForms: NonNullable = }, precision: { label: "精度", - helpText: "小数位数(如:货币用 2 表示保留两位)" + helpText: "总位数" }, scale: { label: "小数位", diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index 3088b10c4c8..0b2ec40e04f 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -13,7 +13,6 @@ "data/Cube:public = false", "data/CurrencyConfig:currencyMode = \"dynamic\"", "data/CurrencyConfig:defaultCurrency = \"CNY\"", - "data/CurrencyConfig:precision = 2", "data/DataEngineDeleteOptions:multi = false", "data/DataEngineInsertOptions:returning = true", "data/DataEngineUpdateOptions:multi = false", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 666025fc110..eae806a367c 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -103,7 +103,6 @@ "data/CubeJoin:name", "data/CurrencyConfig:currencyMode", "data/CurrencyConfig:defaultCurrency", - "data/CurrencyConfig:precision", "data/CurrencyValue:currency", "data/CurrencyValue:value", "data/DataEngineAggregateOptions:aggregations", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 0638d08198b..65d1e049a8f 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 212, - "droppedRefinementSites": 622, + "publishedSchemasWithDroppedRefinements": 211, + "droppedRefinementSites": 609, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -71,7 +71,6 @@ "manifest.objects.element", "manifest.objects.element.fieldGroups", "manifest.objects.element.fields.out.valueType", - "manifest.objects.element.fields.out.valueType.currencyConfig", "manifest.objects.element.fields.out.valueType.inlineColumns.element", "manifest.objects.element.lifecycle", "manifest.pages.element", @@ -174,7 +173,6 @@ "data.options[1].manifest.objects.element", "data.options[1].manifest.objects.element.fieldGroups", "data.options[1].manifest.objects.element.fields.out.valueType", - "data.options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "data.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", "data.options[1].manifest.objects.element.lifecycle", "data.options[1].manifest.pages.element", @@ -262,7 +260,6 @@ "options[1].manifest.objects.element", "options[1].manifest.objects.element.fieldGroups", "options[1].manifest.objects.element.fields.out.valueType", - "options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", "options[1].manifest.objects.element.lifecycle", "options[1].manifest.pages.element", @@ -311,7 +308,6 @@ "data.packages.element.options[1].manifest.objects.element", "data.packages.element.options[1].manifest.objects.element.fieldGroups", "data.packages.element.options[1].manifest.objects.element.fields.out.valueType", - "data.packages.element.options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "data.packages.element.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", "data.packages.element.options[1].manifest.objects.element.lifecycle", "data.packages.element.options[1].manifest.pages.element", @@ -353,7 +349,6 @@ "data.actions.element.in.params.element.in", "data.fieldGroups", "data.fields.out.valueType", - "data.fields.out.valueType.currencyConfig", "data.fields.out.valueType.inlineColumns.element", "data.fields.out.valueType.relatedListFilter.lazy", "data.lifecycle", @@ -535,11 +530,6 @@ "" ] }, - "data/CurrencyConfig": { - "sites": [ - "" - ] - }, "data/DataEngineAggregateOptions": { "sites": [ "filter.options[1].lazy" @@ -659,7 +649,6 @@ "data/Field": { "sites": [ "", - "currencyConfig", "inlineColumns.element", "relatedListFilter.lazy" ] @@ -753,7 +742,6 @@ "actions.element.in.params.element.in", "fieldGroups", "fields.out.valueType", - "fields.out.valueType.currencyConfig", "fields.out.valueType.inlineColumns.element", "fields.out.valueType.relatedListFilter.lazy", "lifecycle", @@ -770,7 +758,6 @@ "sites": [ "", "fields.valueType", - "fields.valueType.currencyConfig", "fields.valueType.inlineColumns.element", "fields.valueType.relatedListFilter.lazy" ] @@ -1002,7 +989,6 @@ "system/AddFieldOperation": { "sites": [ "field", - "field.currencyConfig", "field.inlineColumns.element", "field.relatedListFilter.lazy" ] @@ -1025,7 +1011,6 @@ "system/ChangeSet": { "sites": [ "operations.element.options[0].field", - "operations.element.options[0].field.currencyConfig", "operations.element.options[0].field.inlineColumns.element", "operations.element.options[0].field.relatedListFilter.lazy", "operations.element.options[3].object", @@ -1050,7 +1035,6 @@ "object.actions.element.in.params.element.in", "object.fieldGroups", "object.fields.out.valueType", - "object.fields.out.valueType.currencyConfig", "object.fields.out.valueType.inlineColumns.element", "object.fields.out.valueType.relatedListFilter.lazy", "object.lifecycle", @@ -1086,7 +1070,6 @@ "system/MigrationOperation": { "sites": [ "options[0].field", - "options[0].field.currencyConfig", "options[0].field.inlineColumns.element", "options[0].field.relatedListFilter.lazy", "options[3].object", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index f7994a2cdf7..935de8ba091 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -6480,8 +6480,9 @@ const actionGlobalNavLocationRemoved: MetadataConversion = { * (`Number.isInteger` is false for both). A WELL-FORMED count (`0`, `2`, any * non-negative integer) is untouched. * - * ⚠️ `CurrencyConfigSchema.precision` (under `currencyConfig`) is a different - * surface with its own bounds and alias table — deliberately not walked. + * ⚠️ `currencyConfig.precision` (under `currencyConfig`) is a different key — + * deliberately not walked here; it was removed outright later in this major by + * `currency-config-precision-removed` below. */ const fieldMalformedScalePrecisionRemoved: MetadataConversion = { id: 'field-malformed-scale-precision-removed', @@ -11508,6 +11509,113 @@ const formLayoutInlineGridToVertical: MetadataConversion = { }, }; +/** + * `currencyConfig.precision` — a declared, validated key no renderer or runtime + * ever read (#19992, ADR-0049 enforce-or-remove; triage direction REMOVE under + * ruling 乙 on #19910, 「a currency's decimal places are the currency's, not a + * setting」). objectui's `CurrencyField` derives an amount's decimal places from + * the currency's ISO 4217 minor unit and never looked at the key; its only + * reader was its own #7918 ISO 4217 contradiction check, which policed a width + * nothing applied. + * + * The strip is a pure lossless delete: the key never had an effect to lose. + * It matters for data AT REST more than for authors, because the schema's + * `.overwrite()` (#7918/#11423) BAKED `precision: 2` into the parse output of + * nearly every `currencyConfig` — so stored `sys_metadata` object rows and + * built artifacts carry it without anyone having written it. The stored-row + * and artifact seams replay this entry (`includeRetired`), so those rows are + * served canonical instead of being refused by the now-closed shape. + * + * `retiredFromLoadPath`: `CurrencyConfigSchema` is a `strictObject`, so an + * authored `precision` is refused with the prescription + * (`CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE`, data/field.zod.ts) rather than + * silently rewritten. The never-legal `decimals` / `scale` spellings are NOT + * walked: the closed shape always refused them, so no stored row carries them + * and there is nothing to convert. Fields are a RECORD keyed by name and + * `currencyConfig` sits one level below the field, so the top-level-only + * `stripKeys` runs per field config (pattern of + * `object-tenancy-organization-field-removed`); objects and object extensions + * carry the same `FieldSchema`, so both are walked. + */ +const currencyConfigPrecisionRemoved: MetadataConversion = { + id: 'currency-config-precision-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'object.fields.*.currencyConfig.precision', + summary: + "currency field key 'currencyConfig.precision' removed (#19992, ADR-0049 — no renderer or " + + 'runtime ever read it: an amount\'s decimal places are its currency\'s ISO 4217 minor unit, ' + + 'derived from the currency itself. Its ISO 4217 contradiction check and the default `2` ' + + 'baked into parse output went with it; the field-level `precision` is a total digit count ' + + 'and is untouched)', + apply(stack, emit) { + const stripOn = (input: Dict, collection: string): Dict => + mapCollection(input, collection, (owner, path) => { + const fields = owner.fields; + if (!isDict(fields)) return owner; + let changed = false; + const next: Dict = {}; + for (const [name, def] of Object.entries(fields)) { + if (!isDict(def) || !isDict(def.currencyConfig)) { + next[name] = def; + continue; + } + const stripped = stripKeys( + def.currencyConfig, + ['precision'], + emit, + `${path}.fields.${name}.currencyConfig`, + ); + if (stripped === def.currencyConfig) { + next[name] = def; + continue; + } + next[name] = { ...def, currencyConfig: stripped }; + changed = true; + } + return changed ? { ...owner, fields: next } : owner; + }); + return stripOn(stripOn(stack, 'objects'), 'objectExtensions'); + }, + fixture: { + before: { + objects: [{ + name: 'billing_invoice', + label: 'Invoice', + fields: { + // Authored in the source: the shape the showcase examples carried. + amount: { + type: 'currency', + currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, + }, + // The baked default on a dynamic config — what a stored row carries + // without anyone having written it. + tax: { type: 'currency', currencyConfig: { precision: 2, currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, + // No `precision` under `currencyConfig`: untouched, reference kept. + fee: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } }, + // The FIELD-level `precision` (total digits) is a different key and + // is never walked. + quantity: { type: 'number', precision: 10, scale: 0 }, + }, + }], + }, + after: { + objects: [{ + name: 'billing_invoice', + label: 'Invoice', + fields: { + amount: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }, + tax: { type: 'currency', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, + fee: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } }, + quantity: { type: 'number', precision: 10, scale: 0 }, + }, + }], + }, + // One per stripped key — `fee` and `quantity` are untouched. + expectedNotices: 2, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -11619,6 +11727,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly> = { XCG: 2, XDR: 2, XOF: 0, XPF: 0, XSU: 2, YER: 0, ZAR: 2, ZMW: 2, ZWG: 2, ZWL: 2, }; - -/** - * The fraction digits CLDR gives `code`, or `undefined` for a code outside - * the table (crypto/custom — the open-set, fail-open case above). Case-folded - * to match `Intl.NumberFormat`'s own case-insensitive currency handling, so a - * lowercased `'jpy'` — legal under the length-3 schema — cannot dodge the - * check that `'JPY'` gets. - */ -export function currencyFractionDigits(code: string): number | undefined { - return CURRENCY_FRACTION_DIGITS[code.toUpperCase()]; -} - -/** - * The #7918 verdict for `CurrencyConfigSchema.precision`, checked pre-default - * inside that schema (its `.superRefine`, and the `.overwrite` that decides - * whether the default 2 may be materialized). Returns the issue message when - * `precision` contradicts `currency`'s fraction digits, `undefined` when they - * agree or the currency is unknown (fail-open). - * - * ⛔ Not for the FIELD-level `precision` key. That key is "Total digits" — a - * DECIMAL(18,2) amount declares `precision: 18` — so comparing it with a - * fraction-digit count refuses correct metadata and prescribes a total-digit - * count of 2. `FieldSchema` ran this verdict on it until #20011. - * - * The message's first clause names both numbers, verbatim per the ruling: - * "currency JPY has 0 fraction digits; `precision: 2` contradicts it". - */ -export function currencyPrecisionContradiction( - currency: string, - precision: number, -): string | undefined { - const digits = currencyFractionDigits(currency); - if (digits === undefined || digits === precision) return undefined; - return ( - `currency ${currency.toUpperCase()} has ${digits} fraction digits; ` + - `\`precision: ${precision}\` 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: ${digits}\`, 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.` - ); -} diff --git a/packages/spec/src/data/currency-precision-iso4217.test.ts b/packages/spec/src/data/currency-precision-iso4217.test.ts index 11dd1fe3dcd..91b00479d25 100644 --- a/packages/spec/src/data/currency-precision-iso4217.test.ts +++ b/packages/spec/src/data/currency-precision-iso4217.test.ts @@ -1,229 +1,200 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * #7918 (maintainer ruling 2026-08-12, Option A) — publish-time rejection of a - * declared `currencyConfig.precision` that contradicts the currency's ISO 4217 - * / CLDR fraction digits, when the currency is statically known - * (`currencyConfig.currencyMode: 'fixed'`). + * A currency's decimal places are the currency's — pinned from both keys that + * ever claimed otherwise (ruling 5805782503, letter 乙: 「a currency's decimal + * places are the currency's, not a setting」). * - * #20011 — the FIELD-level `precision` key is NOT judged by this rule. It is - * "Total digits" (the `p` of a DECIMAL(p, s) amount), and a currency's decimal - * places are the currency's, not a setting (ruling 5805782503, letter 乙). The - * field-level block below pins that it parses whatever the currency. + * #19992 — `currencyConfig.precision` is REMOVED (ADR-0049 enforce-or-remove). + * It was declared, validated against ISO 4217 by the #7918 rule, and baked to + * `2` into parse output by the #11423 `.overwrite()` — and read by nothing: + * objectui's `CurrencyField` derives the width from the currency's ISO 4217 + * minor unit. The #7918 / #11423 blocks that used to open this file pinned a + * rule over a key nobody honoured; they are replaced below by the retirement's + * own pins (the refusal, its two alias spellings, the parse output, and the + * ADR-0087 conversion at rest). * - * The rule is deliberately partial: `dynamic` currencyMode has no single - * currency to check against and is out of reach BY DESIGN; codes outside CLDR - * `currencyData` (crypto/custom) fail OPEN. The rule fires ONLY on an AUTHORED - * `precision` — the materialized `.default(2)` on an untouched fixed-JPY - * config must never fire (the permanently-noisy shape the ruling forbids), - * which is what the pre-default anchoring inside `CurrencyConfigSchema` (its - * `.superRefine` before the `.overwrite` that materializes the default) - * exists to deliver. + * #20011 — the FIELD-level `precision` key is "Total digits" (the `p` of a + * DECIMAL(p, s) amount) and is never judged against the currency. Its block + * below is unchanged except for the two cases that used to pin the + * currencyConfig twin's check, which now pin the twin's refusal. * - * Key-vs-value note: these are VALUE verdicts (a declared width judged against - * the currency), so the assertions demand full `safeParse` outcomes — not mere - * key reachability. + * Key-vs-value note: the retirement pins judge a KEY (refused whatever its + * value), so they assert the `unrecognized_keys` envelope — code, path, the + * offending key and the prescription. The #20011 block judges VALUES (a + * total-digit count on each currency class), so it demands full `safeParse` + * outcomes. */ import { describe, expect, it } from 'vitest'; -import { CurrencyConfigSchema, FieldSchema } from './field.zod'; +import { CurrencyConfigSchema, Field, FieldSchema } from './field.zod'; import { ObjectSchema } from './object.zod'; -import { - CURRENCY_FRACTION_DIGITS, - currencyFractionDigits, - currencyPrecisionContradiction, -} from './currency-fraction-digits'; +import { CURRENCY_FRACTION_DIGITS } from './currency-fraction-digits'; +import { applyConversionsToStoredItem } from '../conversions/stored'; -/** The one custom-issue the rule emits, or undefined when the parse passed. */ +/** The first issue, or undefined when the parse passed. */ function firstIssue(result: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }) { return result.success ? undefined : result.error!.issues[0]; } -describe('#7918 — currencyConfig-level anchor (pre-default, inside CurrencyConfigSchema)', () => { - it('rejects an authored precision contradicting a 0-digit currency (JPY + 2)', () => { - const result = CurrencyConfigSchema.safeParse({ - precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY', - }); - expect(result.success).toBe(false); - const issue = firstIssue(result)!; - expect(issue.code).toBe('custom'); - expect(issue.path).toEqual(['precision']); - // The ruling's message shape: both numbers, named. - expect(issue.message).toContain('currency JPY has 0 fraction digits'); - expect(issue.message).toContain('`precision: 2` contradicts it'); - }); +/** The prescription's first clause — the retirement statement itself. */ +const RETIREMENT_LEAD = + '`currencyConfig.precision` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove)'; - it('rejects an authored precision contradicting a 3-digit currency (KWD + 2)', () => { +describe('#19992 — `currencyConfig.precision` is removed: refused with the prescription, whatever its value', () => { + it('refuses the key with the full envelope — code, path, offending key, and the prescription\'s clauses', () => { const result = CurrencyConfigSchema.safeParse({ - precision: 2, currencyMode: 'fixed', defaultCurrency: 'KWD', + precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD', }); expect(result.success).toBe(false); - expect(firstIssue(result)!.message).toContain('currency KWD has 3 fraction digits'); - expect(firstIssue(result)!.message).toContain('`precision: 2` contradicts it'); - }); - - it('THE noisy-shape guard: an untouched fixed-JPY config (defaulted precision) parses clean', () => { - // The default 2 "contradicts" JPY's 0 digits — but it was never authored, - // so the rule must not fire. This is the assertion that proves the - // pre-default anchoring; with a property-level `.default(2)` it goes red - // (measured in this card's reverse verification). Since #11423 the default - // is also no longer MATERIALIZED on this combination (the schema would - // refuse it as authored — see the idempotency block below), so the parsed - // output omits `precision` rather than carrying 2. - const result = CurrencyConfigSchema.safeParse({ - currencyMode: 'fixed', defaultCurrency: 'JPY', - }); - expect(result.success).toBe(true); - expect(result.data!.precision).toBeUndefined(); - }); - - it('dynamic currencyMode is out of reach by design (JPY + 2 + dynamic passes)', () => { - const result = CurrencyConfigSchema.safeParse({ - precision: 2, currencyMode: 'dynamic', defaultCurrency: 'JPY', - }); - expect(result.success).toBe(true); - }); - - it('defaulted currencyMode (dynamic) is equally out of reach', () => { - expect(CurrencyConfigSchema.safeParse({ precision: 2, defaultCurrency: 'JPY' }).success).toBe(true); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + const issue = issues[0] as { code: string; path: PropertyKey[]; keys?: string[]; message: string }; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual([]); + expect(issue.keys).toEqual(['precision']); + // The message IS the author's migration doc — its clauses are the contract. + expect(issue.message).toContain(RETIREMENT_LEAD); + expect(issue.message).toContain('no renderer or runtime ever read it'); + expect(issue.message).toContain( + "a currency amount's decimal places are its currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD)", + ); + expect(issue.message).toContain('Do not move the number to the field-level `precision`'); + expect(issue.message).toContain('Delete the key.'); + expect(issue.message).toContain( + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ); }); - it('unknown codes fail open (fixed BTC + 8 passes — the open-set contract)', () => { - const result = CurrencyConfigSchema.safeParse({ - precision: 8, currencyMode: 'fixed', defaultCurrency: 'BTC', - }); - expect(result.success).toBe(true); - expect(result.data!.precision).toBe(8); + it('refuses an AGREEING value exactly like a contradicting one — the verdict is on the key, not on the width', () => { + // #7918 accepted USD + 2 and refused JPY + 2 (a `custom` issue naming both + // digit counts). Both are now the same unknown-key refusal, and the + // fraction-digit sentence is gone from every outcome. + const cases: Array> = [ + { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, + { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' }, + { precision: 3, currencyMode: 'fixed', defaultCurrency: 'KWD' }, + { precision: 8, currencyMode: 'fixed', defaultCurrency: 'BTC' }, + { precision: 2, currencyMode: 'dynamic', defaultCurrency: 'JPY' }, + { precision: 2 }, + ]; + for (const input of cases) { + const result = CurrencyConfigSchema.safeParse(input); + expect(result.success, JSON.stringify(input)).toBe(false); + const issues = result.error!.issues; + expect(issues, JSON.stringify(input)).toHaveLength(1); + expect(issues[0]!.code).toBe('unrecognized_keys'); + expect(issues[0]!.message).toContain(RETIREMENT_LEAD); + expect(issues[0]!.message).not.toContain('fraction digits;'); + } }); - it('authored precision in fixed mode is judged against the DEFAULTED currency too (CNY + 0)', () => { - // `currencyMode: 'fixed'` with no code pins the schema default CNY as the - // field's one currency; an authored `precision: 0` contradicts its 2. - // The precision was authored, so this is not the noisy shape. - const result = CurrencyConfigSchema.safeParse({ currencyMode: 'fixed', precision: 0 }); - expect(result.success).toBe(false); - expect(firstIssue(result)!.message).toContain('currency CNY has 2 fraction digits'); - expect(firstIssue(result)!.message).toContain('`precision: 0` contradicts it'); + it('the former alias spellings `decimals` / `scale` get the same answer — never a rename to the removed key', () => { + // They were `aliases` pointing an author at `precision`. With the target + // gone, each is answered with the reason instead, and nothing suggests a + // key to move the number to. + for (const key of ['decimals', 'scale'] as const) { + const result = CurrencyConfigSchema.safeParse({ currencyMode: 'fixed', defaultCurrency: 'JPY', [key]: 2 }); + expect(result.success, key).toBe(false); + const issues = result.error!.issues; + expect(issues, key).toHaveLength(1); + const issue = issues[0] as { code: string; path: PropertyKey[]; keys?: string[]; message: string }; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual([]); + expect(issue.keys).toEqual([key]); + expect(issue.message).toContain( + `\`currencyConfig.${key}\` is not a currency configuration key, and nothing replaces it`, + ); + expect(issue.message).toContain("ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD)"); + expect(issue.message).toContain('Delete the key.'); + expect(issue.message).not.toContain('Did you mean'); + // Never keys: no conversion strips them, so the message names no command. + expect(issue.message).not.toContain('os migrate meta'); + } }); - it('a lowercased code cannot dodge the check (Intl-style case folding)', () => { - const result = CurrencyConfigSchema.safeParse({ - precision: 2, currencyMode: 'fixed', defaultCurrency: 'jpy', - }); + it('the surviving aliases still suggest their surviving keys (the table lost two rows, not its job)', () => { + const result = CurrencyConfigSchema.safeParse({ mode: 'fixed' }); expect(result.success).toBe(false); - expect(firstIssue(result)!.message).toContain('currency JPY has 0 fraction digits'); + expect(result.error!.issues[0]!.message).toContain('Did you mean `mode` → `currencyMode`?'); }); - it('agreeing combos parse byte-identically to the `.default(2)` era', () => { - // Measured on origin/main (37b82ed5b) before this change — same shape - // order, same materialized default, byte for byte. The one #11423 flip is - // deliberately NOT in this battery: a bare fixed-JPY config now omits - // `precision` (the schema would refuse the materialized 2 as authored — - // pinned in the idempotency block below); every combination here either - // authored its precision or cannot be refused, so byte-identity holds. + it('parse output carries exactly the two currency keys — the baked `precision: 2` is gone, on every combination', () => { + // Byte-exact. Before #19992 the `.overwrite()` wrote `"precision":2` in + // front on every row except the #11423 guarded class (bare fixed JPY/KRW/ + // KWD), which is why stored rows carry it without anyone writing it. const cases: Array<[Record, string]> = [ - [{ precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, - '{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'], - [{ precision: 0, currencyMode: 'fixed', defaultCurrency: 'JPY' }, - '{"precision":0,"currencyMode":"fixed","defaultCurrency":"JPY"}'], - [{ precision: 3, currencyMode: 'fixed', defaultCurrency: 'KWD' }, - '{"precision":3,"currencyMode":"fixed","defaultCurrency":"KWD"}'], - [{ currencyMode: 'fixed', defaultCurrency: 'USD' }, - '{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'], - [{ currencyMode: 'fixed', defaultCurrency: 'JPY' }, - '{"currencyMode":"fixed","defaultCurrency":"JPY"}'], - [{}, '{"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}'], + [{}, '{"currencyMode":"dynamic","defaultCurrency":"CNY"}'], + [{ currencyMode: 'fixed', defaultCurrency: 'USD' }, '{"currencyMode":"fixed","defaultCurrency":"USD"}'], + [{ currencyMode: 'fixed', defaultCurrency: 'JPY' }, '{"currencyMode":"fixed","defaultCurrency":"JPY"}'], + [{ currencyMode: 'fixed', defaultCurrency: 'KWD' }, '{"currencyMode":"fixed","defaultCurrency":"KWD"}'], + [{ defaultCurrency: 'JPY' }, '{"currencyMode":"dynamic","defaultCurrency":"JPY"}'], ]; for (const [input, expected] of cases) { - expect(JSON.stringify(CurrencyConfigSchema.parse(input))).toBe(expected); - } - }); - - it('the `decimals`/`scale` alias spellings funnel into the canonical key (strict rejection + suggestion)', () => { - // `strictObject` aliases are rejection-with-suggestion, not renames: an - // alias spelling cannot silently carry a contradicting width past the - // check — the author is pointed at `precision`, where the check waits. - for (const alias of ['decimals', 'scale'] as const) { - const result = CurrencyConfigSchema.safeParse({ - currencyMode: 'fixed', defaultCurrency: 'JPY', [alias]: 2, - }); - expect(result.success).toBe(false); - const messages = result.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain(alias); - expect(messages).toContain('precision'); + const once = CurrencyConfigSchema.parse(input); + expect(JSON.stringify(once)).toBe(expected); + // parse(parse(x)) stays idempotent — the property #11423 had to guard + // by hand now holds by construction (nothing is materialized). + expect(JSON.stringify(CurrencyConfigSchema.parse(JSON.parse(JSON.stringify(once))))).toBe(expected); } }); -}); - -// [#11423] (maintainer ruling routed from #9689, 2026-08-24, idempotent -// materialization): the `.overwrite()` never materializes a default the schema -// itself would refuse as authored. Baking `precision: 2` onto a bare fixed -// zero-/three-digit-currency config (JPY/KRW/KWD class) made parse output -// self-rejecting on re-parse — `parse(parse(x))` threw for accepted x, and the -// re-parse chain is the mainline authoring path (`ObjectSchema.create()` -// returns parse output; `objectstack build`'s defineStack parses it again). -// Same one-conditional shape as the #9689 master_detail guard in field.zod.ts. -describe('#11423 — the materialized precision default is never one the schema itself refuses', () => { - it('a bare fixed-JPY config parses green and OMITS precision — parse(parse(x)) is idempotent', () => { - // The card's measured break: parse #1 baked `precision: 2`, parse #2 - // rejected it at `currencyConfig.precision` ("currency JPY has 0 fraction - // digits; `precision: 2` contradicts it"). Absent is the honest spelling. - const once = CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'JPY' }); - expect(once.precision).toBeUndefined(); - expect('precision' in once).toBe(false); - const again = CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once))); - expect(again.success).toBe(true); - expect(JSON.stringify(again.data)).toBe(JSON.stringify(once)); - }); - it('parse is IDEMPOTENT through the mainline create() → defineStack chain (the chain that carried the defect)', () => { - const field = FieldSchema.parse({ - name: 'amount', label: 'Amount', type: 'currency', - currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }, - }); - expect(FieldSchema.safeParse(JSON.parse(JSON.stringify(field))).success).toBe(true); - const obj = ObjectSchema.create({ - name: 'invoice', label: 'Invoice', - fields: { amount: { label: 'Amount', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' } } }, - }); - expect(ObjectSchema.safeParse(obj).success).toBe(true); - }); - - it('an AUTHORED contradictory precision is still rejected with the named message (the guard narrows materialization, not the rule)', () => { - const result = CurrencyConfigSchema.safeParse({ - precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY', + it('crosses the object door, located at the field — and `tsc` refuses the literal at the Field.currency factory', () => { + // The tsc channel: the key is off `CurrencyConfig`'s input type, so a + // literal in a typed position does not compile (TS2353). This is the + // instrument that found the three showcase objects that wrote the key. + const amount = Field.currency({ + label: 'Amount', + // @ts-expect-error — `precision` is not a CurrencyConfig key (#19992) + currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, }); + // The parse channel, for sources `tsc` never sees (JSON, YAML, stored + // bodies through a write door): the closed shape refuses it, located. + const result = ObjectSchema.safeParse({ name: 'invoice', label: 'Invoice', fields: { amount } }); expect(result.success).toBe(false); - const issue = firstIssue(result)!; - expect(issue.code).toBe('custom'); - expect(issue.path).toEqual(['precision']); - expect(issue.message).toContain('currency JPY has 0 fraction digits'); - expect(issue.message).toContain('`precision: 2` contradicts it'); - }); - - it('a bare fixed-USD config still materializes precision 2 byte-identically (the default keeps baking where it is legal — #7918 relocation intact)', () => { - expect(JSON.stringify(CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'USD' }))) - .toBe('{"precision":2,"currencyMode":"fixed","defaultCurrency":"USD"}'); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('unrecognized_keys'); + expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig']); + expect(issues[0]!.message).toContain(RETIREMENT_LEAD); }); +}); - it('the whole refused class skips materialization — 0-digit (KRW) and 3-digit (KWD) fixed currencies omit precision and re-parse green', () => { - for (const code of ['KRW', 'KWD']) { - const once = CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: code }); - expect('precision' in once).toBe(false); - expect(CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once))).success).toBe(true); - } +describe('#19992 — data at rest: a stored row carrying the baked `precision: 2` is served canonical', () => { + // The ADR-0087 conversion `currency-config-precision-removed` is retired from + // the load path (authors are refused, above) and replayed by the stored-row + // seam, which is what keeps rows written under the old `.overwrite()` + // loadable. The registry's own fixture test proves the transform in + // isolation; this pins the seam an operator's data actually goes through. + const storedRow = { + name: 'invoice', + label: 'Invoice', + fields: { + amount: { label: 'Amount', type: 'currency', currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' } }, + tax: { label: 'Tax', type: 'currency', currencyConfig: { precision: 2, currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, + qty: { label: 'Qty', type: 'number', precision: 10, scale: 0 }, + }, + }; + + it('the row as stored is refused by today\'s object door — so the seam is load-bearing, not cosmetic', () => { + const result = ObjectSchema.safeParse(storedRow); + expect(result.success).toBe(false); + expect(result.error!.issues.every((i) => i.code === 'unrecognized_keys')).toBe(true); }); - it('combinations the superRefine cannot refuse keep materializing — dynamic mode and unknown fixed codes', () => { - // dynamic + JPY: no single currency to check against, baked 2 re-parses - // green (the superRefine only judges `fixed`); unknown fixed code: the - // digit table fails OPEN, so 2 is never refused. - expect(CurrencyConfigSchema.parse({ defaultCurrency: 'JPY' }).precision).toBe(2); - expect(CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'BTC' }).precision).toBe(2); - for (const input of [{ defaultCurrency: 'JPY' }, { currencyMode: 'fixed', defaultCurrency: 'BTC' }]) { - const once = CurrencyConfigSchema.parse(input); - expect(CurrencyConfigSchema.safeParse(JSON.parse(JSON.stringify(once))).success).toBe(true); - } + it('applyConversionsToStoredItem strips the key from every currencyConfig, leaves the field-level precision, and the result parses', () => { + const notices: string[] = []; + const converted = applyConversionsToStoredItem('object', storedRow, { + onNotice: (n) => notices.push(`${n.conversionId}@${n.path}`), + }); + expect(converted.fields.amount.currencyConfig).toEqual({ currencyMode: 'fixed', defaultCurrency: 'USD' }); + expect(converted.fields.tax.currencyConfig).toEqual({ currencyMode: 'dynamic', defaultCurrency: 'CNY' }); + // The FIELD-level total-digit count is a different key and survives. + expect(converted.fields.qty).toEqual({ label: 'Qty', type: 'number', precision: 10, scale: 0 }); + expect(notices.filter((n) => n.startsWith('currency-config-precision-removed@'))).toHaveLength(2); + expect(ObjectSchema.safeParse(converted).success).toBe(true); + // Copy-on-write: the stored input is not mutated. + expect(storedRow.fields.amount.currencyConfig).toHaveProperty('precision', 2); }); }); @@ -306,20 +277,23 @@ describe('#20011 — field-level `precision` is total digits, never judged again expect(reparsed.data!.fields.amount.precision).toBe(18); }); - it('the currencyConfig-level check still reaches through the FieldSchema door (a different key; unchanged)', () => { + it('flipped (#19992): the currencyConfig twin no longer judges a width — it refuses the key itself, at the FieldSchema door', () => { + // Was: a `custom` issue at ['currencyConfig', 'precision'] naming both + // fraction-digit counts. The twin key is gone, so the refusal is the + // closed shape's, located at the config object, carrying the prescription. const result = FieldSchema.safeParse({ ...base, currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' }, }); expect(result.success).toBe(false); const issue = firstIssue(result)!; - expect(issue.code).toBe('custom'); - expect(issue.path).toEqual(['currencyConfig', 'precision']); - expect(issue.message).toContain('currency JPY has 0 fraction digits'); - expect(issue.message).toContain('Declare `precision: 0`'); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['currencyConfig']); + expect(issue.message).toContain(RETIREMENT_LEAD); + expect(issue.message).not.toContain('fraction digits;'); }); - it('with both keys authored, only the currencyConfig anchor fires — the field-level key raises nothing', () => { + it('flipped (#19992): with both keys authored, only the removed twin is refused — the field-level key still raises nothing', () => { const result = FieldSchema.safeParse({ ...base, precision: 2, currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' }, @@ -327,34 +301,58 @@ describe('#20011 — field-level `precision` is total digits, never judged again expect(result.success).toBe(false); const issues = result.error!.issues; expect(issues).toHaveLength(1); - expect(issues[0].code).toBe('custom'); - expect(issues[0].path).toEqual(['currencyConfig', 'precision']); - expect(issues[0].message).toContain('currency JPY has 0 fraction digits'); - expect(issues[0].message).toContain('Declare `precision: 0`'); + expect(issues[0].code).toBe('unrecognized_keys'); + expect(issues[0].path).toEqual(['currencyConfig']); + expect(issues[0].message).toContain(RETIREMENT_LEAD); }); }); -describe('#7918 — the digit table itself', () => { - it("carries the card's measured anchors", () => { +describe('the CLDR digit table — kept for the `iso_4217_currency` value domain, which reads its key set', () => { + it("carries the #7918 card's measured anchors", () => { // 0: JPY/KRW/CLP/ISK/VND — 2: USD/EUR/CNY/GBP — 3: KWD/BHD/OMR/TND - for (const c of ['JPY', 'KRW', 'CLP', 'ISK', 'VND']) expect(currencyFractionDigits(c)).toBe(0); - for (const c of ['USD', 'EUR', 'CNY', 'GBP']) expect(currencyFractionDigits(c)).toBe(2); - for (const c of ['KWD', 'BHD', 'OMR', 'TND']) expect(currencyFractionDigits(c)).toBe(3); + for (const c of ['JPY', 'KRW', 'CLP', 'ISK', 'VND']) expect(CURRENCY_FRACTION_DIGITS[c]).toBe(0); + for (const c of ['USD', 'EUR', 'CNY', 'GBP']) expect(CURRENCY_FRACTION_DIGITS[c]).toBe(2); + for (const c of ['KWD', 'BHD', 'OMR', 'TND']) expect(CURRENCY_FRACTION_DIGITS[c]).toBe(3); }); - it('answers undefined for codes outside CLDR (the fail-open contract)', () => { - for (const c of ['BTC', 'ETH', 'ZZZ']) expect(currencyFractionDigits(c)).toBeUndefined(); + it('has no entry for codes outside CLDR (crypto/custom)', () => { + for (const c of ['BTC', 'ETH', 'ZZZ']) expect(Object.prototype.hasOwnProperty.call(CURRENCY_FRACTION_DIGITS, c)).toBe(false); }); it('is a full CLDR snapshot, not a hand-typed subset', () => { // CLDR 48.0 currencyData carries 162 codes (see the module's provenance - // block). A shrunk table silently widens the fail-open surface. + // block). A shrunk table silently narrows the value domain's member set. expect(Object.keys(CURRENCY_FRACTION_DIGITS).length).toBe(162); }); - - it('the shared verdict names both numbers and stays silent on agreement/unknown', () => { - expect(currencyPrecisionContradiction('JPY', 2)).toContain('currency JPY has 0 fraction digits'); - expect(currencyPrecisionContradiction('JPY', 0)).toBeUndefined(); - expect(currencyPrecisionContradiction('BTC', 8)).toBeUndefined(); - }); }); + +/* + * ⭐ ON THE ABSENCE HALF — why this retirement has no tree-scoped TEXT pin, and + * what stands in its place (the `dashboard-chart-structure-refusal.test.ts` + * precedent). The retirement playbook's default is a tree-scoped absence pin; + * a reader who finds none here must not conclude one was forgotten. + * + * A text sweep works when the retired key's NAME leaves the tree. `precision` + * does not leave: it stays authorable as the FIELD-level total-digit count on + * every numeric field, and appears thousands of times across this repository + * as that key and as prose. What is retired is a key IN A POSITION — + * `fields..currencyConfig.precision` — which a grep either matches + * everywhere or, scoped down by hand, matches only the sites its author + * already knew about: the file-scoped failure the tree-scoped rule exists to + * prevent, wearing a tree-scoped costume. + * + * The instruments that DO cover the position, both repo-wide and both already + * required in CI: + * + * 1. `tsc`. The key is off `CurrencyConfig`'s input type, so every object + * literal that writes it in a typed position fails to compile — the + * `@ts-expect-error` in the object-door case above holds that from this + * side, and the example's own `typecheck` refuses the key where the three + * `examples/app-showcase` objects used to write it (measured on this + * retirement by putting it back in one: TS2353 at that line). + * 2. The parse door. `CurrencyConfigSchema` is a closed `strictObject`, so an + * authored key that reaches any parse — `objectstack validate`, the + * metadata-protocol publish gate, `defineStack` — is refused with the + * prescription, in JSON and YAML sources `tsc` never sees. Stored rows and + * built artifacts go through the conversion seam pinned above instead. + */ diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index b94a4690cf7..79cd91a0cc6 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -75,7 +75,12 @@ export const fieldForm = defineForm({ // Number field options { field: 'min', visibleWhen: "data.type == 'number' || data.type == 'currency'", helpText: 'Minimum value' }, { field: 'max', visibleWhen: "data.type == 'number' || data.type == 'currency'", helpText: 'Maximum value' }, - { field: 'precision', visibleWhen: "data.type == 'currency' || data.type == 'number'", helpText: 'Decimal places (e.g., 2 for $10.50)' }, + // #19992 — the help text follows the key's describe ("Total digits") + // and the object designer's quick-add row (`object.form.ts`): the old + // "Decimal places (e.g., 2 for $10.50)" taught the one reading the + // contract refuses. A currency's decimal places are its ISO 4217 minor + // unit and are declared nowhere. + { field: 'precision', visibleWhen: "data.type == 'currency' || data.type == 'number'", helpText: 'Total digits' }, { field: 'scale', visibleWhen: "data.type == 'number'", helpText: 'Number of decimal digits' }, // Every `visibleWhen` below is a MEANINGFULNESS gate, not a parse gate: // `FieldSchema` accepts each key on any type, and each is mirrored from diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 4e2babb144d..b96dc9b7894 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -73,7 +73,6 @@ describe('SelectOptionSchema', () => { describe('CurrencyConfigSchema', () => { it('should accept valid currency config with all fields', () => { const validConfig: CurrencyConfig = { - precision: 2, currencyMode: 'dynamic', defaultCurrency: 'USD', }; @@ -81,28 +80,31 @@ describe('CurrencyConfigSchema', () => { expect(() => CurrencyConfigSchema.parse(validConfig)).not.toThrow(); }); - it('should apply default values', () => { + it('should apply default values — and materialize no decimal-places key (#19992)', () => { const config = CurrencyConfigSchema.parse({}); - - expect(config.precision).toBe(2); - expect(config.currencyMode).toBe('dynamic'); - expect(config.defaultCurrency).toBe('CNY'); - }); - it('should accept precision from 0 to 10', () => { - const validPrecisions = [0, 2, 4, 8, 10]; - - validPrecisions.forEach(precision => { - expect(() => CurrencyConfigSchema.parse({ precision })).not.toThrow(); - }); - }); - - it('should reject invalid precision values', () => { - const invalidPrecisions = [-1, 11, 15, 1.5]; - - invalidPrecisions.forEach(precision => { - expect(() => CurrencyConfigSchema.parse({ precision })).toThrow(); - }); + // Byte-exact: the removed `.overwrite()` used to bake `precision: 2` in + // front of these two keys. + expect(JSON.stringify(config)).toBe('{"currencyMode":"dynamic","defaultCurrency":"CNY"}'); + }); + + it('#19992 — refuses `precision` at EVERY value, in range or not: the key was removed, not re-bounded', () => { + // Before #19992: 0..10 parsed, and -1 / 11 / 15 / 1.5 were refused by the + // key's own number bounds (too_small / too_big / invalid_type). Now the + // key is not on the shape, so every value is refused the same way — as an + // unrecognized key carrying the removal prescription, never a range error. + for (const precision of [0, 2, 4, 8, 10, -1, 11, 15, 1.5]) { + const result = CurrencyConfigSchema.safeParse({ precision }); + expect(result.success, `precision: ${precision}`).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('unrecognized_keys'); + expect(issues[0]!.path).toEqual([]); + expect((issues[0] as { keys?: string[] }).keys).toEqual(['precision']); + expect(issues[0]!.message).toContain( + '`currencyConfig.precision` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove)', + ); + } }); it('should accept both currency modes', () => { @@ -350,17 +352,32 @@ describe('FieldSchema', () => { } }); - it('does NOT touch CurrencyConfigSchema.precision — a different surface with its own bounds', () => { - // The currency config keeps its own `.int().min(0).max(10)` contract - // and its `scale → precision` alias table (#7501-thread trap): `10` is - // legal there, and `scale` under currencyConfig renames to precision - // rather than being judged by Field.scale's contract. - const result = FieldSchema.safeParse({ - name: 'price', label: 'Price', type: 'currency', - currencyConfig: { precision: 10 }, - }); - expect(result.success).toBe(true); - if (result.success) expect(result.data.currencyConfig?.precision).toBe(10); + it('does NOT reach under currencyConfig — whose decimal-places spellings are refused as unknown keys there (#19992)', () => { + // This card judges the FIELD-level digit counts. `currencyConfig` has + // its own answer, and since #19992 it is a refusal, not a bound: its + // `precision` was removed and its `scale` / `decimals` spellings (once + // aliases pointing at it) are refused with the same prescription. A + // well-formed `10` is refused exactly like a malformed one would be — + // as an unknown key, never by Field.precision/scale's number contract. + const cases: Array<[key: 'precision' | 'scale' | 'decimals', lead: string]> = [ + ['precision', '`currencyConfig.precision` was removed in @objectstack/spec 17.5.0'], + ['scale', '`currencyConfig.scale` is not a currency configuration key'], + ['decimals', '`currencyConfig.decimals` is not a currency configuration key'], + ]; + for (const [key, lead] of cases) { + const result = FieldSchema.safeParse({ + name: 'price', label: 'Price', type: 'currency', + currencyConfig: { [key]: 10 }, + }); + expect(result.success, key).toBe(false); + const issues = result.error!.issues; + expect(issues, key).toHaveLength(1); + expect(issues[0]!.code).toBe('unrecognized_keys'); + expect(issues[0]!.path).toEqual(['currencyConfig']); + expect(issues[0]!.message).toContain(lead); + // No rename is offered: the alias to `precision` went with the key. + expect(issues[0]!.message).not.toContain('Did you mean'); + } }); }); @@ -1639,7 +1656,6 @@ describe('Field Factory Helpers', () => { name: 'price', label: 'Price', currencyConfig: { - precision: 2, currencyMode: 'dynamic', defaultCurrency: 'USD', }, @@ -1648,7 +1664,6 @@ describe('Field Factory Helpers', () => { expect(currencyField.type).toBe('currency'); expect(currencyField.currencyConfig?.currencyMode).toBe('dynamic'); expect(currencyField.currencyConfig?.defaultCurrency).toBe('USD'); - expect(currencyField.currencyConfig?.precision).toBe(2); }); it('should create currency field with fixed currency mode', () => { @@ -1656,7 +1671,6 @@ describe('Field Factory Helpers', () => { name: 'salary', label: 'Salary', currencyConfig: { - precision: 2, currencyMode: 'fixed', defaultCurrency: 'CNY', }, @@ -1673,7 +1687,6 @@ describe('Field Factory Helpers', () => { label: 'Revenue', type: 'currency' as const, currencyConfig: { - precision: 4, currencyMode: 'dynamic' as const, defaultCurrency: 'EUR', }, @@ -1682,7 +1695,6 @@ describe('Field Factory Helpers', () => { const result = FieldSchema.safeParse(validField); expect(result.success).toBe(true); if (result.success) { - expect(result.data.currencyConfig?.precision).toBe(4); expect(result.data.currencyConfig?.currencyMode).toBe('dynamic'); expect(result.data.currencyConfig?.defaultCurrency).toBe('EUR'); } @@ -1697,33 +1709,25 @@ describe('Field Factory Helpers', () => { }; const result = FieldSchema.parse(field); - expect(result.currencyConfig?.precision).toBe(2); - expect(result.currencyConfig?.currencyMode).toBe('dynamic'); - expect(result.currencyConfig?.defaultCurrency).toBe('CNY'); + // #19992: exactly the two keys — no baked `precision: 2` any more. + expect(result.currencyConfig).toEqual({ currencyMode: 'dynamic', defaultCurrency: 'CNY' }); }); - it('should reject invalid precision values', () => { - const invalidField = { - name: 'amount', - label: 'Amount', - type: 'currency' as const, - currencyConfig: { - precision: -1, - }, - }; - - expect(() => FieldSchema.parse(invalidField)).toThrow(); - - const tooHighPrecision = { - name: 'amount', - label: 'Amount', - type: 'currency' as const, - currencyConfig: { - precision: 11, - }, - }; - - expect(() => FieldSchema.parse(tooHighPrecision)).toThrow(); + it('#19992 — refuses a currencyConfig `precision` of any value as an unknown key, not as a bad number', () => { + // -1 and 11 used to be refused by the key's own 0..10 bounds; 2 used to + // parse. All three now meet the same refusal: the key is not declared. + for (const precision of [-1, 11, 2]) { + const result = FieldSchema.safeParse({ + name: 'amount', label: 'Amount', type: 'currency' as const, + currencyConfig: { precision }, + }); + expect(result.success, `precision: ${precision}`).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('unrecognized_keys'); + expect(issues[0]!.path).toEqual(['currencyConfig']); + expect(issues[0]!.message).toContain('Delete the key.'); + } }); it('should reject invalid currency codes', () => { @@ -1781,7 +1785,6 @@ describe('Field Factory Helpers', () => { readonly: false, description: 'Total budget for the project', currencyConfig: { - precision: 2, currencyMode: 'dynamic', defaultCurrency: 'USD', }, @@ -1793,21 +1796,23 @@ describe('Field Factory Helpers', () => { expect(currencyField.description).toBe('Total budget for the project'); }); - it('should support high precision for cryptocurrency', () => { + it('accepts a cryptocurrency code as a fixed currency — with no decimal-places key to declare (#19992)', () => { + // Codes are validated by length only, so BTC is legal under `fixed`. + // Before #19992 this test declared `precision: 8` for the satoshi; that + // key was never read by any renderer and is now refused. The field + // parses with exactly the two currency keys. const cryptoField = Field.currency({ name: 'btc_balance', label: 'Bitcoin Balance', currencyConfig: { - precision: 8, // Bitcoin uses 8 decimal places currencyMode: 'fixed', defaultCurrency: 'BTC', }, }); expect(cryptoField.type).toBe('currency'); - expect(cryptoField.currencyConfig?.precision).toBe(8); - expect(cryptoField.currencyConfig?.currencyMode).toBe('fixed'); - expect(cryptoField.currencyConfig?.defaultCurrency).toBe('BTC'); + const parsed = FieldSchema.parse(cryptoField); + expect(parsed.currencyConfig).toEqual({ currencyMode: 'fixed', defaultCurrency: 'BTC' }); }); }); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 073aa383d6a..f92a37f50b2 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -28,11 +28,6 @@ import { suggestDefaultValueToken, } from './default-value-shape'; import { AddressSchema, FILE_REFERENCE_TYPES, MULTI_CAPABLE_TYPES, MULTI_OPTION_TYPES, REFERENCE_VALUE_TYPES } from './field-value.zod'; -// #7918 — the ISO 4217 / CLDR fraction-digit contradiction check (maintainer -// ruling 2026-08-12, Option A), read by `CurrencyConfigSchema.precision`'s -// anchor. The FIELD-level `precision` key is total digits and is not compared -// against the currency (#20011 — see the note in `FieldSchema`'s superRefine). -import { currencyPrecisionContradiction } from './currency-fraction-digits'; import { ValueDomainSchema } from '../shared/value-domain.zod'; /** @@ -414,6 +409,58 @@ export const LocationCoordinatesSchema = lazySchema(() => z.object({ accuracy: z.number().optional().describe('Accuracy in meters'), })); +/** + * Why `currencyConfig` has no decimal-places key — the one reason, shared by the + * tombstone for the removed key and the answers for its two natural spellings. + */ +const CURRENCY_DECIMAL_PLACES_ARE_THE_CURRENCYS = + 'a currency amount\'s decimal places are its currency\'s ISO 4217 minor unit (2 for USD, ' + + '0 for JPY, 3 for KWD), which every display face derives from the currency itself, so ' + + 'there is no decimal-places setting to declare. Do not move the number to the ' + + 'field-level `precision`: that key is the amount\'s TOTAL digit count (a DECIMAL(18,2) ' + + 'amount declares `precision: 18`), not its decimal places.'; + +/** + * Prescriptions for the decimal-places spellings this surface refuses. + * + * #19992 (ADR-0049 enforce-or-remove, triage direction REMOVE under ruling 乙 on + * #19910 — 「a currency's decimal places are the currency's, not a setting」): + * `precision` was a declared, validated key that no renderer or runtime ever + * read. objectui's `CurrencyField` derives the width from the currency's ISO + * 4217 minor unit (`currencyFractionDigits(currency)`, the same line at the + * `.objectui-sha` pin and at objectui `main`), and no code read the key at all — + * measured with a lit control (`currencyConfig.currencyMode` IS read) over + * objectstack `packages/**` + `examples/**`, objectui at the pin and at `main`, + * and cloud `main`. Its only reader was + * its own ISO 4217 contradiction check (#7918), which policed a width nothing + * applied; that check, the `.overwrite()` that baked a default `2` into parse + * output (#11423), and the `decimals` / `scale` → `precision` aliases all left + * with the key. Stored rows and built artifacts carrying the baked `2` are + * stripped on rehydration by the ADR-0087 conversion + * `currency-config-precision-removed`. + * + * `decimals` and `scale` were never keys here — they were `aliases` pointing an + * author at `precision`. With the target gone the alias would point at a + * refusal, so each gets the same answer as the tombstone instead of a bare + * unknown-key report (the edit-distance fallback reaches neither). + */ +const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly> = { + precision: + '`currencyConfig.precision` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) ' + + '— no renderer or runtime ever read it: ' + + CURRENCY_DECIMAL_PLACES_ARE_THE_CURRENCYS + + ' Delete the key. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + decimals: + '`currencyConfig.decimals` is not a currency configuration key, and nothing replaces it: ' + + CURRENCY_DECIMAL_PLACES_ARE_THE_CURRENCYS + + ' Delete the key.', + scale: + '`currencyConfig.scale` is not a currency configuration key, and nothing replaces it: ' + + CURRENCY_DECIMAL_PLACES_ARE_THE_CURRENCYS + + ' Delete the key.', +}; + /** * Currency Configuration Schema * Configuration for currency field type supporting multi-currency @@ -423,97 +470,24 @@ export const LocationCoordinatesSchema = lazySchema(() => z.object({ * - Cryptocurrency codes (BTC, ETH, etc.) * - Custom business-specific codes * Stricter validation can be implemented at the application layer based on business requirements. + * + * There is no decimal-places key: a currency's decimal places are its ISO 4217 + * minor unit (see {@link CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE} for the + * removed `precision`). */ export const CurrencyConfigSchema = lazySchema(() => strictObject({ surface: 'this currency configuration', history: FIELD_HISTORY, - aliases: { decimals: 'precision', scale: 'precision', mode: 'currencyMode', currency: 'defaultCurrency', code: 'defaultCurrency', isoCode: 'defaultCurrency' }, + aliases: { mode: 'currencyMode', currency: 'defaultCurrency', code: 'defaultCurrency', isoCode: 'defaultCurrency' }, + guidance: CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE, }, { - /** - * #7918 — `.default(2)` moved off this property and into the `.overwrite()` - * below, and this placement is load-bearing. A property-level default - * materializes AT PARSE, so a refinement over the parsed object cannot tell - * an authored `precision: 2` from an untouched one — and a rule firing on - * the baked default would refuse every untouched JPY currencyConfig (the - * permanently-noisy shape the ruling forbids). Declared `.optional()`, the - * authored-vs-absent distinction survives to the `.superRefine` below; - * the `.overwrite` then materializes the same `2` AFTER the check, so parse - * OUTPUT is byte-identical to the `.default(2)` era. The `default: 2` - * annotation states the contract default to schema consumers without - * touching parse order — the `autonumberFormat` pattern below. - */ - precision: z.number().int().min(0).max(10).optional().meta({ - description: 'Decimal precision (default: 2)', - default: 2, - }), currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way.'), defaultCurrency: z.string().length(3).default('CNY').describe('The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency.'), -}).superRefine((config, ctx) => { - // #7918 (maintainer ruling 2026-08-12, Option A): an AUTHORED `precision` - // that contradicts the statically-known currency's ISO 4217 / CLDR fraction - // digits is a publish-time error — `precision: 2` on a fixed-JPY config asks - // for two digits of a minor unit the yen does not have; `precision: 2` on - // fixed-KWD silently drops the third fils digit that exists. - // - // Deliberately partial, per the ruling: only `currencyMode: 'fixed'` pins a - // single currency to check against — `dynamic` mode is out of reach BY - // DESIGN (do not "improve" it), and codes outside CLDR `currencyData` - // (crypto/custom) fail OPEN. `config.precision` here is pre-`.overwrite`, - // so `undefined` means "not authored" — the defaulted 2 on an untouched - // fixed-JPY config never fires. `defaultCurrency` and `currencyMode` keep - // their property defaults: in authored-`fixed` mode the (possibly defaulted) - // `defaultCurrency` IS the field's one currency, so an authored `precision` - // contradicting it is judged even when the code itself was defaulted. - if (config.precision === undefined || config.currencyMode !== 'fixed') return; - const contradiction = currencyPrecisionContradiction(config.defaultCurrency, config.precision); - if (contradiction !== undefined) { - ctx.addIssue({ code: 'custom', path: ['precision'], message: contradiction }); - } -}).overwrite((config) => { - // #7918 — the relocated `.default(2)`, applied AFTER the check above. - // `.overwrite()` rather than `.transform()` per the measured #6926 precedent - // (view.zod.ts `foldFormGroupsIntoSections`): it keeps this schema a - // `ZodObject` (a pipe has no `.extend` and answers shape introspection with - // an empty set), and checks run in attachment order, so the superRefine - // above always sees the pre-materialized value. Rebuilt in shape order so - // the output is byte-identical to the `.default(2)` era: - // `{precision, currencyMode, defaultCurrency}`, `precision` always a number - // — except on the guarded combination below. The one accepted cost, same as - // #6926's: the INFERRED output type still declares `precision?` even though - // a parsed config normally carries it (ADR-0122 forbids hand-narrowing - // `CurrencyConfigParsed`); the runtime contract is the enforced one. - // - // #11423 (maintainer ruling on #9689, 2026-08-24, routed to this twin — - // 「The same principle prescribes the fix for the #7918 currency twin - // (#11423) — the spec seat should route it under this ruling.」): NEVER - // materialize a default the schema itself would refuse as authored. The - // superRefine above rejects an AUTHORED `precision: 2` on a fixed - // zero-/three-fraction-digit currency (JPY/KRW/KWD class), and the two - // spellings are indistinguishable to any later parse BY DESIGN — so baking - // `2` onto a bare fixed-JPY config made parse output self-rejecting on - // re-parse, and `ObjectSchema.create()` → `defineStack` re-parses on the - // MAINLINE app-build path (measured: `parse(parse(x))` threw at - // `currencyConfig.precision` for accepted x). A bare fixed config whose - // currency contradicts the default 2 therefore parses to output that OMITS - // `precision`: renderers already derive display width from the currency - // when the key is absent, and built artifacts stop carrying a value the - // schema itself refuses. Every other combination keeps byte-identity — - // `dynamic` mode and unknown codes (fail-open table) can never be refused, - // so they keep materializing. The #9689 master_detail `deleteBehavior` - // conditional in `FieldSchema`'s `.overwrite()` below is the worked - // precedent; #11423 is its recorded currency twin. - if ( - config.precision === undefined && - config.currencyMode === 'fixed' && - currencyPrecisionContradiction(config.defaultCurrency, 2) !== undefined - ) { - return config; - } - return { - precision: config.precision ?? 2, - currencyMode: config.currencyMode, - defaultCurrency: config.defaultCurrency, - }; + // #19992 — no `.superRefine()` / `.overwrite()` here any more. The #7918 + // ISO 4217 contradiction check and the #11423 default-materializing + // `.overwrite()` both existed only for the removed `precision` key (see + // CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE above), so parse output is now + // exactly `{ currencyMode, defaultCurrency }`. })); /** @@ -1256,8 +1230,10 @@ export const FieldSchema = lazySchema(() => { // `scale: 2.5` silently got no enforcement at all: the declared-but-inert // shape that hides AI-authored metadata errors. Refuse it at the producer // instead (ADR-0078 declared=enforced; house pattern `z.number().int().min(0)`). - // ⚠️ `CurrencyConfigSchema.precision` above is a DIFFERENT surface with its - // own alias table (`scale → precision` there) — do not conflate. + // ⚠️ `currencyConfig` has NO decimal-places key: its `precision` was removed + // (#19992) and its `decimals` / `scale` spellings are refused with the same + // prescription — see CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE. Do not + // conflate this total-digit count with a currency's decimal places. precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer)'), // #18972 — and an UPPER bound, for the same declared=enforced reason one // axis over: `scale` is unrenderable above 100 at every consumer, so a @@ -2361,8 +2337,8 @@ export const FieldSchema = lazySchema(() => { // refused the ruled contract and prescribed a total-digit count of 2. // ⛔ Do not reinstate a comparison here, and do not add a total-digit // coherence rule for currency alone: no numeric type has one. The - // `currencyConfig.precision` twin is a DIFFERENT key ("Decimal precision") - // and keeps its own check inside `CurrencyConfigSchema`. + // `currencyConfig.precision` twin that kept its own copy of the check was + // removed with it (#19992): a currency's decimal places are declared nowhere. // #9689 (maintainer ruling 2026-08-19, Q1 = A): an AUTHORED // `deleteBehavior: 'set_null'` on a `master_detail` is a publish-time error. @@ -2447,14 +2423,14 @@ export const FieldSchema = lazySchema(() => { // The TYPE-CONDITIONAL defaults of this schema — relocated key-level // `.default()`s, applied AFTER the checks above. `.overwrite()` rather // than `.transform()` per the measured #6926 precedent - // (`CurrencyConfigSchema` in this file is the sibling): it keeps this + // (view.zod.ts `foldFormGroupsIntoSections`): it keeps this // schema a `ZodObject` (a pipe has no `.extend` and answers shape // introspection with an empty set), and checks run in attachment order, so // the superRefine above always sees the pre-materialized value. Each key // is re-inserted at its SHAPE position (Zod emits parse output in shape // order), so output is byte-identical to the key-level `.default()` era // wherever the value is unchanged. The one accepted cost, same as the - // currency precedent's: the INFERRED output type declares the key optional + // #6926 precedent's: the INFERRED output type declares the key optional // (`deleteBehavior?`, `unique?`) even though a parsed field carries it // (ADR-0122 forbids hand-narrowing the inferred type); the runtime // contract is the enforced one. @@ -2497,8 +2473,8 @@ export const FieldSchema = lazySchema(() => { // (both resolve to `cascade` — measured in the #9689 exhaustion matrix, // pinned in `engine-cascade-delete.test.ts`), and built artifacts stop // carrying a value the schema itself refuses. Every other type keeps - // byte-identity, and the #7918 currency `precision` twin of this landmine - // is #11423 — same principle, its own card. + // byte-identity. (The #7918 currency `precision` twin of this landmine, + // #11423, went away with that key in #19992 — nothing left to materialize.) // // #9784 — materialize the default ONLY on reference types. `deleteBehavior` // has no meaning on a non-reference field: the engine's diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__CurrencyConfig__precision.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__CurrencyConfig__precision.ts new file mode 100644 index 00000000000..97840a2e4fa --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__CurrencyConfig__precision.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #19992 — ADR-0049 enforce-or-remove (triage direction REMOVE under ruling 乙 +// on #19910: 「a currency's decimal places are the currency's, not a +// setting」). `currencyConfig.precision` was declared and validated against +// ISO 4217 (#7918) but read by NOTHING — measured with a positive control +// (`currencyConfig.currencyMode` IS read) over objectstack, objectui at the +// `.objectui-sha` pin and at `main`, and cloud `main`. objectui's +// `CurrencyField` derives decimal places from the currency's ISO 4217 minor +// unit and never read the key; its own contradiction check was its only +// reader. The `decimals` / `scale` aliases that pointed authors at it went +// with it (they now answer with the same prescription). +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// removal ships on the 17.x line (launch-window convention: accept-set +// narrowings ride minor releases) and the prescription lives at the major +// boundary where `migrate meta` users look. `CurrencyConfigSchema` is +// `strictObject`, so the route is strict deletion + a `guidance` entry carrying +// the prescription (no retiredKey tombstone — the key is out of the walked +// shape entirely). Sources and stored rows are rewritten by the D2 conversion +// `currency-config-precision-removed`, which strips the key from every field's +// `currencyConfig` on objects and object extensions — including the `2` the +// old `.overwrite()` baked into parse output. +export const entry = 'data/CurrencyConfig:precision'; diff --git a/packages/spec/src/migrations/entries/semantic/18.currency-config-precision-retired.ts b/packages/spec/src/migrations/entries/semantic/18.currency-config-precision-retired.ts new file mode 100644 index 00000000000..b3f083aae79 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.currency-config-precision-retired.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #19992 (ADR-0049 enforce-or-remove; triage direction REMOVE under ruling 乙 +// on #19910: 「a currency's decimal places are the currency's, not a +// setting」) — the D3 entry of the `currency-config-precision-removed` family +// (ruling B on #17152: one D3 entry per retirement family, even when D2 is +// lossless). Registered key: `data/CurrencyConfig:precision`; the never-accepted +// `decimals` / `scale` spellings are answered by the same prescription and have +// no stored form to convert. The delete changes no rendered amount; what it +// leaves is a width belief, and code outside the platform that may have read the +// served key. +export const entry: SemanticMigration = { + id: 'currency-config-precision-retired', + surface: 'object.fields.*.currencyConfig.precision — the decimal-places key of a currency ' + + 'field\'s configuration, and its never-accepted `decimals` / `scale` spellings', + replacement: '(removed — nothing replaces it.) A currency amount\'s decimal places are its ' + + 'currency\'s ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD), derived from the ' + + 'currency itself and declared nowhere. Delete the key. Do not move the number to the ' + + 'field-level `precision`: that key is the amount\'s total digit count, not its decimal ' + + 'places, and it is unchanged.', + reason: 'The D2 conversion `currency-config-precision-removed` deletes the key from every ' + + 'field\'s `currencyConfig` on objects and object extensions — in author sources, in stored ' + + 'object rows and in built artifacts, which can carry a `2` the old schema wrote into parse ' + + 'output without anyone authoring it — and the delete is lossless: no renderer or runtime ' + + 'ever read the key. Every display face derives the width from the currency. Two judgments ' + + 'remain, and neither is a rewrite. First, a width that never applied: the old contradiction ' + + 'check judged an authored value only on a `fixed` field whose code has a known ISO 4217 ' + + 'minor unit, so on a `dynamic` field, and on a `fixed` field whose code has none (a crypto ' + + 'or custom code), an author could declare a width other than the one the field displays — ' + + 'and read amounts as if it applied. Whether the displayed width is acceptable for that ' + + 'field is the author\'s call. Second, code the chain cannot reach: a plugin, integration or ' + + 'export of your own that read `currencyConfig.precision` from served object metadata now ' + + 'finds no key, and must derive the width from the field\'s currency the way the platform\'s ' + + 'renderers always did.', + acceptanceCriteria: 'No field\'s `currencyConfig` carries `precision`, `decimals` or `scale` — ' + + 'in sources, in stored object rows or in built artifacts; the parse refuses each by name ' + + 'with the prescription, and a stored row or artifact written before the upgrade loads ' + + 'without a refusal over it. No code of your own reads `currencyConfig.precision`; where it ' + + 'needed a width, it derives one from the field\'s currency. Every currency field renders ' + + 'its amounts exactly as before the upgrade, because the key never changed a rendered ' + + 'amount. `os migrate meta --stored --apply` rewrites stored rows so the per-row notice ' + + 'stops. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; ' + + 'apply them by hand.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e7ff7c80d24..66cb3ea8b51 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5396,7 +5396,15 @@ const step18: MigrationStep = { + 'per-value prescription naming `columns`; the D2 conversion ' + '`form-layout-inline-grid-to-vertical` rewrites them to `vertical` (behaviour-preserving, ' + '`columns` untouched) on `object-form` page components, on every form payload a view ' - + 'carries, and on the assembled-manifest `viewItems` channel.', + + 'carries, and on the assembled-manifest `viewItems` channel. ' + + 'It also removes `currencyConfig.precision` (#19992, ADR-0049 enforce-or-remove): ' + + 'declared and validated against ISO 4217, read by no renderer or runtime — a currency ' + + 'amount\'s decimal places are its currency\'s ISO 4217 minor unit, derived from the ' + + 'currency itself. The D2 conversion `currency-config-precision-removed` strips it from ' + + 'every field\'s `currencyConfig` as a pure lossless delete, which matters most at rest: ' + + 'the schema used to bake `precision: 2` into parse output, so stored object rows and ' + + 'built artifacts carry it without anyone having written it. Retired from the load path; ' + + 'an authored key is refused with the prescription.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5437,6 +5445,7 @@ const step18: MigrationStep = { 'report-joined-chart-removed', 'view-overlay-owner-hidden-removed', 'form-layout-inline-grid-to-vertical', + 'currency-config-precision-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -7287,6 +7296,48 @@ const step18: MigrationStep = { + 'over a fixture where the condition excludes rows returns the filtered aggregate (strictly ' + 'smaller for a positive sum over excluded rows), not the unfiltered one.', }, + // #19992 (ADR-0049 enforce-or-remove; triage direction REMOVE under ruling 乙 + // on #19910: 「a currency's decimal places are the currency's, not a + // setting」) — the D3 entry of the `currency-config-precision-removed` family + // (ruling B on #17152: one D3 entry per retirement family, even when D2 is + // lossless). Registered key: `data/CurrencyConfig:precision`; the never-accepted + // `decimals` / `scale` spellings are answered by the same prescription and have + // no stored form to convert. The delete changes no rendered amount; what it + // leaves is a width belief, and code outside the platform that may have read the + // served key. + { + id: 'currency-config-precision-retired', + surface: 'object.fields.*.currencyConfig.precision — the decimal-places key of a currency ' + + 'field\'s configuration, and its never-accepted `decimals` / `scale` spellings', + replacement: '(removed — nothing replaces it.) A currency amount\'s decimal places are its ' + + 'currency\'s ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD), derived from the ' + + 'currency itself and declared nowhere. Delete the key. Do not move the number to the ' + + 'field-level `precision`: that key is the amount\'s total digit count, not its decimal ' + + 'places, and it is unchanged.', + reason: 'The D2 conversion `currency-config-precision-removed` deletes the key from every ' + + 'field\'s `currencyConfig` on objects and object extensions — in author sources, in stored ' + + 'object rows and in built artifacts, which can carry a `2` the old schema wrote into parse ' + + 'output without anyone authoring it — and the delete is lossless: no renderer or runtime ' + + 'ever read the key. Every display face derives the width from the currency. Two judgments ' + + 'remain, and neither is a rewrite. First, a width that never applied: the old contradiction ' + + 'check judged an authored value only on a `fixed` field whose code has a known ISO 4217 ' + + 'minor unit, so on a `dynamic` field, and on a `fixed` field whose code has none (a crypto ' + + 'or custom code), an author could declare a width other than the one the field displays — ' + + 'and read amounts as if it applied. Whether the displayed width is acceptable for that ' + + 'field is the author\'s call. Second, code the chain cannot reach: a plugin, integration or ' + + 'export of your own that read `currencyConfig.precision` from served object metadata now ' + + 'finds no key, and must derive the width from the field\'s currency the way the platform\'s ' + + 'renderers always did.', + acceptanceCriteria: 'No field\'s `currencyConfig` carries `precision`, `decimals` or `scale` — ' + + 'in sources, in stored object rows or in built artifacts; the parse refuses each by name ' + + 'with the prescription, and a stored row or artifact written before the upgrade loads ' + + 'without a refusal over it. No code of your own reads `currencyConfig.precision`; where it ' + + 'needed a width, it derives one from the field\'s currency. Every currency field renders ' + + 'its amounts exactly as before the upgrade, because the key never changed a rendered ' + + 'amount. `os migrate meta --stored --apply` rewrites stored rows so the per-row notice ' + + 'stops. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; ' + + 'apply them by hand.', + }, { id: 'dashboard-header-modal-target-page-only', surface: @@ -17585,6 +17636,28 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // carries the judgement the strip cannot: an author who wrote a non-FK // condition wanted a join this runtime does not perform. 'data/CubeJoin:sql', + // #19992 — ADR-0049 enforce-or-remove (triage direction REMOVE under ruling 乙 + // on #19910: 「a currency's decimal places are the currency's, not a + // setting」). `currencyConfig.precision` was declared and validated against + // ISO 4217 (#7918) but read by NOTHING — measured with a positive control + // (`currencyConfig.currencyMode` IS read) over objectstack, objectui at the + // `.objectui-sha` pin and at `main`, and cloud `main`. objectui's + // `CurrencyField` derives decimal places from the currency's ISO 4217 minor + // unit and never read the key; its own contradiction check was its only + // reader. The `decimals` / `scale` aliases that pointed authors at it went + // with it (they now answer with the same prescription). + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look. `CurrencyConfigSchema` is + // `strictObject`, so the route is strict deletion + a `guidance` entry carrying + // the prescription (no retiredKey tombstone — the key is out of the walked + // shape entirely). Sources and stored rows are rewritten by the D2 conversion + // `currency-config-precision-removed`, which strips the key from every field's + // `currencyConfig` on objects and object extensions — including the `2` the + // old `.overwrite()` baked into parse output. + 'data/CurrencyConfig:precision', // #14478 — maintainer ruling 2026-09-02 ("ruled B"): the unit of a // duration-shaped `z.number()` key lives in the key name, and no existing // offender is grandfathered. `DriverOptions.timeout` said "Timeout in ms" in diff --git a/packages/spec/src/shared/value-domain.zod.ts b/packages/spec/src/shared/value-domain.zod.ts index 16909a0b9a8..af3a32eb34a 100644 --- a/packages/spec/src/shared/value-domain.zod.ts +++ b/packages/spec/src/shared/value-domain.zod.ts @@ -22,8 +22,7 @@ * Prime Directive #2 keeps business logic out of the spec, and the earlier * TSDoc of this vocabulary read that as "the list does not live here". The * ruling above settles it the other way for this one predicate, on the same - * footing as the package's existing shared verdicts: `currencyPrecisionContradiction` - * (a checked-in CLDR table and the rule read over it), `filterVerdict`, the + * footing as the package's existing shared verdicts: `filterVerdict`, the * comparand-shape door. Each is a pure, dependency-free function two or more * doors must answer IDENTICALLY — and "the same answer on both doors" is * exactly what a shared contract is for. The predicate takes no I/O, holds no diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 62a227a40ce..762a116e6a7 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -52,7 +52,7 @@ fields: { | Type | When to Use | Config | |:-----|:------------|:-------| | `number` | Generic numeric value | `min`, `max`, `precision`, `scale` | -| `currency` | Monetary amounts | `currencyConfig` (precision, currencyMode, defaultCurrency) | +| `currency` | Monetary amounts | `currencyConfig` (currencyMode, defaultCurrency) | | `percent` | Percentage values (0-100) | `min`, `max`, `precision` | ## Date & Time @@ -172,13 +172,12 @@ Stored as JSON on the parent row — no separate table / FK: } ``` -### Currency with Precision +### Currency ```typescript { type: 'currency', currencyConfig: { - precision: 2, currencyMode: 'fixed', // 'fixed' = the column is pinned to // defaultCurrency; 'dynamic' = tenant default defaultCurrency: 'USD', // ISO 4217