Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e6e60da
wip(spec): retire currencyConfig.precision — schema, conversion, regi…
claude Sep 27, 2026
53c44ab
wip(docs,skills,i18n): drop currencyConfig.precision from docs, skill…
claude Sep 27, 2026
acae6ea
wip(spec): registry entry regenerated; authorable-surface row and dro…
claude Sep 27, 2026
e8313de
wip(examples,spec): showcase stops authoring currencyConfig.precision…
claude Sep 27, 2026
a12e057
wip(docs): regenerate references for the removed currencyConfig.preci…
claude Sep 27, 2026
baee423
Merge origin/main into claude/issue-19992-currency-config-precision-r…
claude Sep 27, 2026
952c19c
chore(docs): regenerate references/data/field.mdx on the merged tree …
claude Sep 27, 2026
74cf413
test(spec): state what the showcase typecheck was measured to do
claude Sep 27, 2026
3681939
chore(i18n): regenerate the en metadata-forms bundle for the field pr…
claude Sep 27, 2026
1e6f29b
Merge origin/main into claude/issue-19992-currency-config-precision-r…
claude Sep 27, 2026
0b2e396
chore(docs): regenerate references/data/field.mdx on the merged tree …
claude Sep 27, 2026
71ea994
Merge origin/main (e4621867) into claude/issue-19992-currency-config-…
claude Sep 27, 2026
ccc55f5
Merge origin/main (67047171) into claude/issue-19992-currency-config-…
claude Sep 28, 2026
d18fbf0
fix(spec): give the currency-config-precision-removed family its own …
claude Sep 28, 2026
1b4ea5f
Merge remote-tracking branch 'origin/main' into claude/issue-19992-cu…
claude Sep 28, 2026
a987993
chore(spec): regenerate the four reference pages both sides moved
claude Sep 28, 2026
cdfbc5f
fix(spec): end the currency D3 entry's acceptance text on the house m…
claude Sep 28, 2026
33f53b5
Merge origin/main (a88a1bb3) into claude/issue-19992-currency-config-…
claude Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .changeset/19992-currency-config-precision-retired.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered currency-config-precision-removed -->
28 changes: 8 additions & 20 deletions content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 0 additions & 1 deletion content/docs/data-modeling/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 3 additions & 4 deletions content/docs/data-modeling/validation-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/getting-started/common-patterns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
}
}
]
Expand Down Expand Up @@ -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' } },
},
},
},
Expand Down
1 change: 0 additions & 1 deletion content/docs/protocol/objectql/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,6 @@ annual_revenue:
type: currency
label: Annual Revenue
currencyConfig:
precision: 2
currencyMode: fixed
defaultCurrency: USD # the field's currency under fixed
```
Expand Down
4 changes: 1 addition & 3 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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") |
Expand Down Expand Up @@ -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. |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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") |
Expand Down Expand Up @@ -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") |
Expand Down
3 changes: 1 addition & 2 deletions content/docs/references/shared/value-domain.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading