diff --git a/.changeset/19629-currency-scale-retired.md b/.changeset/19629-currency-scale-retired.md new file mode 100644 index 00000000000..a1e1ecbc43d --- /dev/null +++ b/.changeset/19629-currency-scale-retired.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +--- + +fix(spec,objectql)!: `scale` is retired from the `currency` field type — refused at parse, and no longer enforced on currency writes (#19629) + +Clause-②: no (narrowing) + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. A `currency` field that declares `scale` — any value, `scale: 0` included — no longer parses. The hand-migration prescription is registered under protocol major 18 as `field-currency-scale-refused`. + +A currency's decimal places are the currency's, not a setting. On a currency field the key was three-faced. The metadata-admin field designer offered it as stored metadata; the amount's cell never read it, because a currency amount's fraction digits come from its currency's own ISO 4217 minor unit; and the record validator's `max_scale` branch still refused writes carrying more decimals. An author who set `scale: 3` bought a narrower write contract and no visible change. The maintainer's rulings retire the key from the type rather than aligning the money faces to it. + +**`@objectstack/spec`** — `FieldSchema` refuses `scale` on `type: 'currency'` with a located issue at `scale`. Its remedy: delete the key; the currency's ISO 4217 minor unit decides how the amount displays, and the field's write allowance stays unconstrained. The remedy names no other key to carry the value. No alias and no grace window. `scale` on `number`, `percent`, `rating`, `slider` and `formula` is untouched, and the key's describe now names that set. Studio's object editor no longer offers `scale` on a currency field: the fields grid of the `objectForm` this package registers in `METADATA_FORM_REGISTRY` now shows it only for `number` and `percent`. + +**`@objectstack/objectql`** — the record validator's `max_scale` branch no longer reads `scale` for `currency`, so the type leaves the enforced set. A field definition that reaches the validator without passing `FieldSchema` (stored before this release, or built by hand at runtime) therefore narrows nothing either. `min`, `max` and the finite-number check still apply to `currency`, and `number` / `percent` / `rating` / `slider` still refuse over-scale writes exactly as before. A currency write with more decimals than a former `scale` is now ACCEPTED: the write allowance stays unconstrained, the contract every currency field without `scale` already had. Enforcing a currency width on writes instead was offered to the maintainer and not taken. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `Field.currency({ label: 'Amount', scale: 2 })` | `Field.currency({ label: 'Amount' })` | +| `{ type: 'currency', scale: 2, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` | `{ type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` | + +The one-line fix: delete `scale` from every `currency` field. Nothing replaces it, so ⛔ do not re-declare the value under any other key. The currency's ISO 4217 minor unit decides how the amount displays. + +What an upgrade changes beyond the refusal: + +- **Writes.** A currency value with more decimals than the deleted `scale` is accepted where it used to answer `VALIDATION_FAILED` with field code `max_scale`. +- **Two console faces.** At the console pin measured when this change was written, the grid summary footer and the dashboard metric widget read a currency column's `scale ?? 0`. This change lands only after the console derives both faces from the currency, the way the cell does, and after this repository's console pin has moved past that console change. So in the console bundled with this release, deleting `scale` changes neither face. + +## Who is affected, measured + +AST sweep on `origin/main` `1f89ba0d70`: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 documentation code examples carried `scale`, every one `scale: 2`. All were deleted in this change. No platform object, seed or JSON fixture in the tree declares it. Seven test fixtures pinned the old shape and were re-judged. One of them, a flow oracle that needed a live `scale` gate, moved its field from `currency` to `number`. + + diff --git a/content/docs/concepts/architecture.mdx b/content/docs/concepts/architecture.mdx index 3173ece49ec..ad477bc0bc4 100644 --- a/content/docs/concepts/architecture.mdx +++ b/content/docs/concepts/architecture.mdx @@ -118,7 +118,6 @@ export const Customer = ObjectSchema.create({ annual_revenue: Field.currency({ label: 'Annual Revenue', - scale: 2, }), primary_contact: Field.lookup('contact', { @@ -426,7 +425,6 @@ export const Opportunity = ObjectSchema.create({ amount: Field.currency({ label: 'Amount', - scale: 2, }), customer: Field.lookup('customer', { diff --git a/content/docs/concepts/metadata-driven.mdx b/content/docs/concepts/metadata-driven.mdx index ddfe69a2f5d..cf1bb94fdd2 100644 --- a/content/docs/concepts/metadata-driven.mdx +++ b/content/docs/concepts/metadata-driven.mdx @@ -450,7 +450,6 @@ owner: Field.lookup({ ```typescript annual_revenue: Field.currency({ label: 'Annual Revenue', - scale: 2, min: 0, }), ``` @@ -515,7 +514,6 @@ export const ExampleObject = ObjectSchema.create({ // Currency field price: Field.currency({ label: 'Price', - scale: 2, min: 0, }), diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index 2d4483f95a5..377aa8449f2 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -62,7 +62,7 @@ notes: Field.markdown({ label: 'Notes' }), ```typescript quantity: Field.number({ label: 'Quantity', min: 0, max: 10000, step: 1 }), -price: Field.currency({ label: 'Price', scale: 2, min: 0 }), +price: Field.currency({ label: 'Price', min: 0 }), // percent stores a fraction: 0.15 = 15% (the UI renders it as a percentage) discount: Field.percent({ label: 'Discount', scale: 2, min: 0, max: 1 }), ``` diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 3d74990d404..c3f5a7aee49 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -29,7 +29,7 @@ export const Account = ObjectSchema.create({ { label: 'Healthcare', value: 'healthcare' }, ], }), - annual_revenue: Field.currency({ label: 'Annual Revenue', scale: 2 }), + annual_revenue: Field.currency({ label: 'Annual Revenue' }), owner: Field.user({ label: 'Owner', required: true }), }, diff --git a/content/docs/data-modeling/schema-design.mdx b/content/docs/data-modeling/schema-design.mdx index 979d19cac9d..7eda4ccdd2c 100644 --- a/content/docs/data-modeling/schema-design.mdx +++ b/content/docs/data-modeling/schema-design.mdx @@ -436,7 +436,6 @@ export const Account = ObjectSchema.create({ annual_revenue: Field.currency({ label: 'Annual Revenue', - scale: 2, min: 0, }), diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx index 6e7fd17191a..7faa395551e 100644 --- a/content/docs/protocol/kernel/plugin-spec.mdx +++ b/content/docs/protocol/kernel/plugin-spec.mdx @@ -290,7 +290,6 @@ export const Account = ObjectSchema.create({ annual_revenue: Field.currency({ label: 'Annual Revenue', - scale: 2, }), primary_contact: Field.lookup('contact', { diff --git a/content/docs/protocol/objectql/schema.mdx b/content/docs/protocol/objectql/schema.mdx index 2020d432c9c..de6c8f5f8fd 100644 --- a/content/docs/protocol/objectql/schema.mdx +++ b/content/docs/protocol/objectql/schema.mdx @@ -114,7 +114,6 @@ fields: budget: type: currency label: Budget - scale: 2 precision: 18 account_id: # Snake case for field name type: lookup @@ -161,7 +160,6 @@ export const Project = ObjectSchema.create({ budget: Field.currency({ label: 'Budget', - scale: 2, min: 0, }), @@ -291,7 +289,7 @@ fields: | `valueDomain` | `string` | `text` | Standard the written value must belong to: `iana_time_zone`, `iso_4217_currency` or `iso_3166_alpha2` (the closed vocabulary shared with settings specifiers). | | `min` | `number` | `number`, `currency` | Minimum numeric value. | | `max` | `number` | `number`, `currency` | Maximum numeric value. | -| `scale` | `number` | `number`, `currency` | Decimal places (e.g., `2` for cents). | +| `scale` | `number` | `number`, `percent`, `rating`, `slider` | Decimal places, enforced on writes. Refused on `currency` — delete it there: a currency amount's decimal places are its currency's, so its ISO 4217 minor unit decides the display. | | `precision` | `number` | `number`, `currency` | Total digits (including scale). | | `options` | `array` | `select`, `multiselect` | List of valid values. | | `multiple` | `boolean` | `select`, `lookup` | Allow multiple selections. | @@ -383,7 +381,6 @@ discount_rate: revenue: type: currency label: Annual Revenue - scale: 2 precision: 18 defaultValue: 0 # currency stores a bare number — never { value, currency } ``` @@ -914,7 +911,6 @@ fields: annual_revenue: type: currency label: Annual Revenue - scale: 2 precision: 18 # Relationships diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index b31351d77f2..9ad10d85d8b 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -24,7 +24,6 @@ CREATE TABLE customer ( revenue: type: currency label: Annual Revenue - scale: 2 precision: 18 # Automatically knows: # - Store amount + currency code diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 31bcd42c6a5..25aa7d9105f 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -68,7 +68,7 @@ const result = CurrencyConfigSchema.parse(data); | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | -| **scale** | `integer` | optional | Decimal places (integer 0-100). OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 7e4ddde94f6..eb7035f05c1 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -230,7 +230,7 @@ const result = ApiMethod.parse(data); | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | -| **scale** | `integer` | optional | Decimal places (integer 0-100). OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | @@ -562,7 +562,7 @@ const result = ApiMethod.parse(data); | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | -| **scale** | `integer` | optional | Decimal places (integer 0-100). OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 6485f2ebdbc..5369e9058af 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -68,7 +68,7 @@ Add a new field to an existing object | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | -| **scale** | `integer` | optional | Decimal places (integer 0-100). OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | @@ -488,7 +488,7 @@ Add a new field to an existing object | **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. | | **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | -| **scale** | `integer` | optional | Decimal places (integer 0-100). OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | +| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. | | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | diff --git a/examples/app-crm/src/objects/account.object.ts b/examples/app-crm/src/objects/account.object.ts index 9825a7a20fe..eb58158045a 100644 --- a/examples/app-crm/src/objects/account.object.ts +++ b/examples/app-crm/src/objects/account.object.ts @@ -33,7 +33,6 @@ export const Account = ObjectSchema.create({ }), annual_revenue: Field.currency({ label: 'Annual Revenue', - scale: 2, min: 0, }), website: Field.url({ diff --git a/examples/app-crm/src/objects/opportunity-line-item.object.ts b/examples/app-crm/src/objects/opportunity-line-item.object.ts index 8d3256c9d44..96cd559b991 100644 --- a/examples/app-crm/src/objects/opportunity-line-item.object.ts +++ b/examples/app-crm/src/objects/opportunity-line-item.object.ts @@ -47,7 +47,6 @@ export const OpportunityLineItem = ObjectSchema.create({ }), unit_price: Field.currency({ label: 'Unit Price', - scale: 2, min: 0, }), // Amount = Qty × Unit Price. Kept as a *stored* currency column (so the @@ -59,7 +58,6 @@ export const OpportunityLineItem = ObjectSchema.create({ // showcase InvoiceLine.amount pattern.) amount: Field.currency({ label: 'Amount', - scale: 2, min: 0, expression: cel`record.quantity * record.unit_price`, }), diff --git a/examples/app-crm/src/objects/opportunity.object.ts b/examples/app-crm/src/objects/opportunity.object.ts index 6e87d2ff9d2..8d3f698b9c1 100644 --- a/examples/app-crm/src/objects/opportunity.object.ts +++ b/examples/app-crm/src/objects/opportunity.object.ts @@ -39,7 +39,6 @@ export const Opportunity = ObjectSchema.create({ }), amount: Field.currency({ label: 'Amount', - scale: 2, min: 0, }), probability: Field.percent({ diff --git a/examples/app-showcase/src/data/objects/account.object.ts b/examples/app-showcase/src/data/objects/account.object.ts index 93268bbf250..7996b928ec9 100644 --- a/examples/app-showcase/src/data/objects/account.object.ts +++ b/examples/app-showcase/src/data/objects/account.object.ts @@ -66,7 +66,6 @@ export const Account = ObjectSchema.create({ // strip and grids show "$25,000,000" instead of "25,000,000". annual_revenue: Field.currency({ label: 'Annual Revenue', - scale: 2, min: 0, currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }, }), diff --git a/examples/app-showcase/src/data/objects/client-brief.object.ts b/examples/app-showcase/src/data/objects/client-brief.object.ts index a8cf0a117e5..9c287477d6b 100644 --- a/examples/app-showcase/src/data/objects/client-brief.object.ts +++ b/examples/app-showcase/src/data/objects/client-brief.object.ts @@ -77,7 +77,7 @@ export const ClientBrief = ObjectSchema.create({ // API access by an entitled member is unaffected — redaction applies only // when the request principal is `kind:'share-link'`. internal_notes: Field.text({ label: 'Internal Notes', maxLength: 2000 }), - deal_value: Field.currency({ label: 'Deal Value', scale: 2, min: 0 }), + deal_value: Field.currency({ label: 'Deal Value', min: 0 }), // Owner anchor — auto-stamped on insert; the `public_read` OWD reads it to // decide who may WRITE the row. owner_id: Field.lookup('sys_user', { label: 'Owner' }), diff --git a/examples/app-showcase/src/data/objects/expense-report.object.ts b/examples/app-showcase/src/data/objects/expense-report.object.ts index 60b76b5723a..c24e7e489c2 100644 --- a/examples/app-showcase/src/data/objects/expense-report.object.ts +++ b/examples/app-showcase/src/data/objects/expense-report.object.ts @@ -159,7 +159,7 @@ export const ExpenseLine = ObjectSchema.create({ { label: 'Other', value: 'other', default: true }, ], }), - amount: Field.currency({ label: 'Amount', required: true, scale: 2, min: 0 }), + amount: Field.currency({ label: 'Amount', required: true, min: 0 }), // Whether the expense is billable back to a client — the `billable: true` // filter on the report's `reimbursable_amount` rollup reads this. billable: Field.boolean({ label: 'Billable to client', defaultValue: false }), diff --git a/examples/app-showcase/src/data/objects/external/customer.object.ts b/examples/app-showcase/src/data/objects/external/customer.object.ts index 428765ccf3e..49eece644c4 100644 --- a/examples/app-showcase/src/data/objects/external/customer.object.ts +++ b/examples/app-showcase/src/data/objects/external/customer.object.ts @@ -25,6 +25,6 @@ export const ExternalCustomer = ObjectSchema.create({ name: Field.text({ label: 'Name', searchable: true }), email: Field.text({ label: 'Email' }), region: Field.text({ label: 'Region' }), - lifetime_value: Field.currency({ label: 'Lifetime Value', scale: 2 }), + lifetime_value: Field.currency({ label: 'Lifetime Value' }), }, }); diff --git a/examples/app-showcase/src/data/objects/external/order.object.ts b/examples/app-showcase/src/data/objects/external/order.object.ts index 893f61c17a6..0cf4804b860 100644 --- a/examples/app-showcase/src/data/objects/external/order.object.ts +++ b/examples/app-showcase/src/data/objects/external/order.object.ts @@ -21,7 +21,7 @@ export const ExternalOrder = ObjectSchema.create({ external: { remoteName: 'orders' }, fields: { customer_id: Field.text({ label: 'Customer ID' }), - amount: Field.currency({ label: 'Amount', scale: 2 }), + amount: Field.currency({ label: 'Amount' }), status: Field.text({ label: 'Status' }), placed_on: Field.date({ label: 'Placed On' }), }, 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 8e75331cb9c..3fa545c3adc 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', scale: 2, min: 0, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD', precision: 2 } }), + f_currency: Field.currency({ label: 'Currency', min: 0, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD', precision: 2 } }), f_percent: Field.percent({ label: 'Percent', min: 0, max: 100, defaultValue: 50 }), // ── Date & time ────────────────────────────────────────────────────── diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index 79a8c242141..b22edd77038 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -23,7 +23,7 @@ export const Product = ObjectSchema.create({ name: Field.text({ label: 'Name', required: true, searchable: true, maxLength: 120 }), sku: Field.text({ label: 'SKU', searchable: true, maxLength: 40 }), description: Field.text({ label: 'Description', maxLength: 200 }), - unit_price: Field.currency({ label: 'Unit Price', scale: 2, min: 0 }), + unit_price: Field.currency({ label: 'Unit Price', min: 0 }), active: Field.boolean({ label: 'Active', defaultValue: true }), }, }); @@ -280,7 +280,6 @@ export const InvoiceLine = ObjectSchema.create({ }), unit_price: Field.currency({ label: 'Unit Price', - scale: 2, min: 0, readonlyWhen: P`parent.status == 'paid'`, }), @@ -298,7 +297,6 @@ export const InvoiceLine = ObjectSchema.create({ // the client-sent value is stored as-is. amount: Field.currency({ label: 'Amount', - scale: 2, min: 0, expression: cel`record.quantity * record.unit_price`, }), diff --git a/examples/app-showcase/src/data/objects/project.object.ts b/examples/app-showcase/src/data/objects/project.object.ts index 0abe2631fd5..6a42b0bd62b 100644 --- a/examples/app-showcase/src/data/objects/project.object.ts +++ b/examples/app-showcase/src/data/objects/project.object.ts @@ -60,8 +60,8 @@ export const Project = ObjectSchema.create({ { label: 'Red', value: 'red', color: '#EF4444' }, ], }), - budget: Field.currency({ label: 'Budget', scale: 2, min: 0 }), - spent: Field.currency({ label: 'Spent', scale: 2, min: 0, defaultValue: 0 }), + budget: Field.currency({ label: 'Budget', min: 0 }), + spent: Field.currency({ label: 'Spent', min: 0, defaultValue: 0 }), budget_remaining: Field.formula({ label: 'Budget Remaining', expression: cel`(record.budget == null ? 0 : record.budget) - (record.spent == null ? 0 : record.spent)`, diff --git a/packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts b/packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts index 0c6285bd38e..623ed806e1e 100644 --- a/packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts @@ -152,7 +152,7 @@ const ExternalCustomer = ObjectSchema.create({ name: Field.text({ label: 'Name', searchable: true }), email: Field.text({ label: 'Email' }), region: Field.text({ label: 'Region' }), - lifetime_value: Field.currency({ label: 'Lifetime Value', scale: 2 }), + lifetime_value: Field.currency({ label: 'Lifetime Value' }), }, }); diff --git a/packages/metadata-core/test/injected-column-provenance.test.ts b/packages/metadata-core/test/injected-column-provenance.test.ts index 34bb8bc8bff..31035d773b1 100644 --- a/packages/metadata-core/test/injected-column-provenance.test.ts +++ b/packages/metadata-core/test/injected-column-provenance.test.ts @@ -41,7 +41,7 @@ const remoteFields = () => ({ name: { type: 'text', label: 'Name' }, email: { type: 'text', label: 'Email' }, region: { type: 'text', label: 'Region' }, - lifetime_value: { type: 'currency', label: 'Lifetime Value', scale: 2 }, + lifetime_value: { type: 'currency', label: 'Lifetime Value' }, }); /** Showcase-shaped federated object (pre-injection, as authored). */ diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index c9040a88fa4..a548a4fe237 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -1225,8 +1225,9 @@ describe('validateRecord — a fraction-stored percent derives `scale + 2` (#193 // Nothing derives for a type that carries no percent semantics. Each of // these is refused at scale + 1, which is precisely what a fraction-stored // percent now accepts — so this block discriminates the percent arm from - // a blanket loosening of the branch. - for (const type of ['number', 'currency', 'slider', 'rating'] as const) { + // a blanket loosening of the branch. `currency` is not in the list: it + // left the enforced set (#19629 — pinned in its own block below). + for (const type of ['number', 'slider', 'rating'] as const) { const s = { fields: { n: { type, label: 'N', scale: 2 } } }; expect(fieldsOf(s, { n: 1.23 })).toBeNull(); expect(fieldsOf(s, { n: 1.234 })?.[0]).toMatchObject({ @@ -1267,3 +1268,76 @@ describe('validateRecord — a fraction-stored percent derives `scale + 2` (#193 expect(errs?.[0].message).toBe('Rate的小数位数不能超过 4 位(当前 5 位)'); }); }); + +/** + * #19629 — `currency` LEAVES the `max_scale` enforced set. + * + * Maintainer ruling 5791803339 (batch #215 item 1, letter B): `scale` is + * retired from the `currency` type. `FieldSchema` refuses the key there at + * parse (pinned in `packages/spec`'s `field-currency-scale-refused.test.ts`), + * and this branch stops reading `def.scale` for the type — so a declaration + * that reaches the validator anyway (a stored field that predates the refusal, + * a hand-built runtime schema) narrows nothing either. + * + * Three pins, per the ruling: a currency write with more decimals is ACCEPTED + * when nothing declares a width (today's contract, unchanged — ruling + * 5805782503, letter 乙: a currency's write allowance stays unconstrained); + * the same write is accepted with a legacy `scale` on the def (the change); + * and `number` / `percent` still refuse the identical over-scale write (the + * controls that prove the branch is live in this harness, not deleted). + */ +describe('validateRecord — `currency` is outside the max_scale enforced set (#19629)', () => { + const fieldsOf = ( + schema: Parameters[0], + data: Record, + mode: 'insert' | 'update' = 'insert', + ) => { + try { + validateRecord(schema, data, mode); + } catch (e) { + return (e as ValidationError).fields; + } + return null; + }; + + it('accepts a currency write with more decimals when nothing declares a width — unchanged', () => { + const s = { fields: { amount: { type: 'currency', label: 'Amount' } } }; + expect(fieldsOf(s, { amount: 1234.56789 })).toBeNull(); + expect(fieldsOf(s, { amount: 1234.56789 }, 'update')).toBeNull(); + }); + + it('accepts the same write when a legacy currency def still carries `scale` — the key no longer narrows it', () => { + // A def that bypassed `FieldSchema` (stored before the refusal, or built + // by hand at runtime). Before #19629 both writes answered `max_scale` + // with `constraint: { scale: 2, actual: 5 }`. + const legacy = { fields: { amount: { type: 'currency', label: 'Amount', scale: 2 } } }; + expect(fieldsOf(legacy, { amount: 1.23456 })).toBeNull(); + expect(fieldsOf(legacy, { amount: 1.23456 }, 'update')).toBeNull(); + // A string-carried amount (a CSV cell) takes the same path after coercion. + expect(fieldsOf(legacy, { amount: '1.23456' })).toBeNull(); + }); + + it('keeps the rest of the numeric branch on currency — min, max and the finite-number check', () => { + const bounded = { fields: { amount: { type: 'currency', label: 'Amount', scale: 2, min: 0, max: 100 } } }; + expect(fieldsOf(bounded, { amount: -0.001 })?.[0]).toMatchObject({ field: 'amount', code: 'min_value' }); + expect(fieldsOf(bounded, { amount: 100.001 })?.[0]).toMatchObject({ field: 'amount', code: 'max_value' }); + expect(fieldsOf(bounded, { amount: 'abc' })?.[0]).toMatchObject({ field: 'amount', code: 'invalid_number' }); + }); + + it('CONTROLS — number and percent still refuse the identical over-scale write', () => { + const number = { fields: { n: { type: 'number', label: 'N', scale: 2 } } }; + expect(fieldsOf(number, { n: 1.23456 })?.[0]).toMatchObject({ + field: 'n', + code: 'max_scale', + constraint: { scale: 2, actual: 5 }, + }); + // A fraction-stored percent's allowance is `scale + 2`, so 5 places is + // one past a `scale: 2` percent's four. + const percent = { fields: { p: { type: 'percent', label: 'P', scale: 2 } } }; + expect(fieldsOf(percent, { p: 0.12345 })?.[0]).toMatchObject({ + field: 'p', + code: 'max_scale', + constraint: { scale: 4, actual: 5 }, + }); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 2e2d3981d29..8f49d1cd14b 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -28,8 +28,11 @@ * - `min` / `max` (number/currency/percent/rating/slider) * - `scale` more decimal places than the field's STORED allowance → * `max_scale` (#7501; rejection, NEVER rounding — - * maintainer ruling 2026-08-11). The allowance is the - * declared `scale` on every numeric type but ONE: on a + * maintainer ruling 2026-08-11), on `number` / `percent` / + * `rating` / `slider` — ⛔ NOT `currency`, from which the + * key is retired (ruling 5791803339, batch #215 item 1 + * letter B). The allowance is the declared `scale` on each + * of those but ONE: on a * fraction-stored `percent` it is `scale + 2`, because * there `scale` counts DISPLAYED percentage-point decimals * and the stored fraction carries the same quantity two @@ -785,7 +788,27 @@ function validateOne( // has no defined meaning, and inventing one here (floor? round?) would be // the consumer-side guessing PD #12 forbids — a malformed declaration // stays unenforced exactly as every declaration was before this branch. + // + // ── `currency` is OUTSIDE the enforced set (#19629) ── + // Maintainer ruling 5791803339 (batch #215 item 1, letter B): `scale` is + // retired from the `currency` type. `FieldSchema` refuses the key there at + // parse, with the remedy ruling 5805782503 (batch #218 item 2, letter 乙) + // words (delete the key; the currency's ISO 4217 minor unit decides its + // display), so no authored currency field declares it any more — and this + // branch stops reading `def.scale` for the type, so a declaration that + // reaches here anyway (a stored field that predates the refusal, a + // hand-built runtime schema) narrows nothing either. The key was the + // currency field's one enforced effect: the amount's cell never read it, + // so an author who set it bought a narrower write contract and no visible + // change. `min` / `max` and the finite-number check above still apply to + // `currency` unchanged. + // ⛔ Not replaced by a read of any other key: a currency's write + // allowance stays unconstrained (ruling 乙, today's contract) — a currency + // write carries whatever decimals it carries, exactly as it always has on + // a currency field that declared no `scale`. Enforcing a currency width on + // writes (the first ruling's B′) was offered and NOT taken. if ( + t !== 'currency' && def.scale !== undefined && Number.isInteger(def.scale) && def.scale >= 0 @@ -800,8 +823,8 @@ function validateOne( // the same quantity two places further right: the widget offers // `12.34`, the write is `0.1234`. So its stored value is allowed // `scale + 2` places. A whole-percent field (`max > 1`) stores the - // displayed number itself and is unchanged, as is every OTHER numeric - // type — `number` / `currency` / `rating` / `slider` carry no percent + // displayed number itself and is unchanged, as is every OTHER type in + // the enforced set — `number` / `rating` / `slider` carry no percent // semantics and nothing derives for them. // ⛔ The derivation is read from `percentScaleOf`, the spec's single // source of truth for "what magnitude is this percentage stored at" — diff --git a/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts b/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts index bb7b5616bce..44b70d62fa5 100644 --- a/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts +++ b/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts @@ -4,7 +4,13 @@ * #11060 end-to-end oracle — the hotcrm quote-flow shape (hotcrm#1206), * reproduced in-tree because that repo is out of reach from here: a flow * computes a discounted money value (`180000 * (1 - 30/100)` = - * `125999.99999999999`) and writes it into a `scale: 2` currency field. + * `125999.99999999999`) and writes it into a `scale: 2` field. + * + * The field is a `number`, not the `currency` hotcrm#1206 declares: #19629 + * retired `scale` from the `currency` type (refused at parse, and no longer + * enforced on currency writes), so a currency field can no longer be the gate + * this oracle needs. The subject — a flow-computed value lands within the + * declared `scale` of the field it is written to — is unchanged. * * Real stack end to end: ObjectKernel + ObjectQLPlugin + better-sqlite3 * `:memory:` driver + AutomationServicePlugin — so the #7501 `scale` @@ -39,13 +45,13 @@ function makeSqliteDriver() { }); } -/** The quote shape: a `scale: 2` currency field, as hotcrm#1206 declares it. */ +/** The quote shape: a `scale: 2` total (a `number` — see the header on `currency`). */ const quote = { name: 'quote', label: 'Quote', fields: { title: { name: 'title', label: 'Title', type: 'text' }, - total: { name: 'total', label: 'Total', type: 'currency', scale: 2 }, + total: { name: 'total', label: 'Total', type: 'number', scale: 2 }, }, }; @@ -114,7 +120,7 @@ describe('flow-computed money lands within its declared scale (#11060, oracle fo expect(await quoteByTitle('raw'), 'no row may persist from the refused write').toBeFalsy(); }); - it('ORACLE: round(x * 100) / 100 writes 126000 into the scale-2 currency field, end to end', async () => { + it('ORACLE: round(x * 100) / 100 writes 126000 into the scale-2 total field, end to end', async () => { await boot(); automation.registerFlow( 'rounded', diff --git a/packages/spec/src/data/field-currency-scale-refused.test.ts b/packages/spec/src/data/field-currency-scale-refused.test.ts new file mode 100644 index 00000000000..963fef83520 --- /dev/null +++ b/packages/spec/src/data/field-currency-scale-refused.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #19629 — `scale` is RETIRED from the `currency` field type (maintainer ruling + * 5791803339, batch #215 item 1, letter B): a currency field carrying `scale` + * is refused at parse. The remedy is worded by ruling 5805782503 (batch #218 + * item 2, letter 乙 — a currency's decimal places are the currency's, not a + * setting): delete the key; the currency's ISO 4217 minor unit decides its + * display, and its write allowance stays unconstrained. It names no other key + * to carry the value, and the remedy pins below hold both halves: the ruled + * wording is present, and the retired pointer is absent. + * + * On a currency field the key was offered by the field designer, never read by + * the amount's cell, and still enforced on writes by `packages/objectql`'s + * `max_scale` branch — a narrower write contract bought with no visible + * change. The write seam drops `currency` from its enforced set in the same + * change (pinned in that package's `record-validator.test.ts`); this file pins + * the authoring half: the parse door, and the OFFER beside it. The object + * designer's quick-add grid (`object.form.ts`, the registered `object` form) + * showed its `scale` input on currency rows until the at-tier review of this + * change caught it; a door that refuses a key a registered form still offers is + * the offer-vs-door shape the retirement exists to remove, so the last block + * reads every registered form's `scale` rows against a currency field. + * + * Key-vs-value note: the rule judges the KEY on one type, whatever its value, + * so the refusal is asserted as a full `safeParse` failure located at + * `['scale']`, and every control is a full `safeParse` success — never mere + * absence of `unrecognized_keys`. + */ + +import { describe, expect, it } from 'vitest'; +import { Field, FieldSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; +import { objectForm } from './object.form'; +import { METADATA_FORM_REGISTRY } from '../system/metadata-form-registry'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; + +/** The issues located at the field's `scale` key, or [] when the parse passed. */ +function scaleIssues(result: { success: boolean; error?: { issues: Issue[] } }): Issue[] { + return result.success ? [] : result.error!.issues.filter((i) => i.path[i.path.length - 1] === 'scale'); +} + +/** + * The ruled remedy, held on the message itself: the wording is the contract + * here (ruling 乙 rules the prescription, not only the refusal), so the pin + * reads the first sentence verbatim, the two ruled clauses, and the ABSENCE of + * any key the author could be sent to instead. + */ +function expectRuledRemedy(message: string): void { + expect(message.startsWith('`scale` is not valid on a `currency` field — delete the key.')).toBe(true); + expect(message).toContain('the currency\'s ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) decides how the amount displays'); + expect(message).toContain('the field\'s write allowance stays unconstrained'); + expect(message).not.toMatch(/currencyConfig|precision/); +} + +describe('#19629 — `scale` on a `currency` field is refused at parse', () => { + it('refuses the designer-produced shape, located at `scale`, with the ruled remedy: delete the key, and no other key named', () => { + const result = FieldSchema.safeParse({ name: 'amount', label: 'Amount', type: 'currency', scale: 3 }); + expect(result.success).toBe(false); + const issues = scaleIssues(result); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('custom'); + expect(issues[0].path).toEqual(['scale']); + expectRuledRemedy(issues[0].message); + }); + + it('refuses every declared value, `scale: 0` and `scale: 2` included — it is the key that is retired', () => { + for (const scale of [0, 2, 10]) { + const result = FieldSchema.safeParse({ name: 'amount', label: 'Amount', type: 'currency', scale }); + expect(result.success, `scale: ${scale}`).toBe(false); + expect(scaleIssues(result), `scale: ${scale}`).toHaveLength(1); + } + }); + + it('refuses it beside a `currencyConfig` too, with the same remedy — the block neither licenses the key nor receives its value', () => { + const result = FieldSchema.safeParse({ + name: 'amount', + label: 'Amount', + type: 'currency', + scale: 2, + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, + }); + expect(result.success).toBe(false); + const issues = scaleIssues(result); + expect(issues).toHaveLength(1); + // The field that most invites a "move it into the block" remedy gets the + // same prescription as every other: delete the key. + expectRuledRemedy(issues[0].message); + }); + + it('fires through the `Field.currency()` helper and `ObjectSchema` — the path an object document crosses', () => { + const result = ObjectSchema.safeParse({ + name: 'invoice', + label: 'Invoice', + fields: { amount: Field.currency({ label: 'Amount', scale: 2, min: 0 }) }, + }); + expect(result.success).toBe(false); + const issues = scaleIssues(result); + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['fields', 'amount', 'scale']); + expectRuledRemedy(issues[0].message); + }); +}); + +describe('#19629 — CONTROLS: what the refusal must leave alone', () => { + it('a currency field without `scale` parses, and its parse output re-parses unchanged', () => { + const once = FieldSchema.parse({ name: 'amount', label: 'Amount', type: 'currency', min: 0 }); + expect('scale' in once).toBe(false); + expect(FieldSchema.parse(once)).toEqual(once); + }); + + it('following the remedy parses: each refused shape, with `scale` deleted and nothing added, is accepted', () => { + const refused: Record[] = [ + { name: 'amount', label: 'Amount', type: 'currency', scale: 3 }, + { name: 'amount', label: 'Amount', type: 'currency', scale: 2, min: 0, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'KWD' } }, + ]; + for (const shape of refused) { + expect(FieldSchema.safeParse(shape).success).toBe(false); + const remedied = { ...shape }; + delete remedied.scale; + expect(Object.keys(remedied)).toEqual(Object.keys(shape).filter((k) => k !== 'scale')); + const result = FieldSchema.safeParse(remedied); + expect(result.success, JSON.stringify(remedied)).toBe(true); + if (result.success) expect('scale' in result.data).toBe(false); + } + }); + + it('every type the key still applies to keeps it — number, percent, rating, slider', () => { + for (const type of ['number', 'percent', 'rating', 'slider'] as const) { + const result = FieldSchema.safeParse({ name: 'n', label: 'N', type, scale: 2 }); + expect(result.success, type).toBe(true); + if (result.success) expect(result.data.scale, type).toBe(2); + } + }); + + it('the describe names the type set the key still applies to, and the currency refusal with the ruled remedy', () => { + const description = (FieldSchema.shape as Record).scale?.description ?? ''; + expect(description).toContain('Applies to `number`, `percent`, `rating` and `slider`'); + expect(description).toContain('REFUSED on a `currency` field — delete it there'); + expect(description).toContain('the currency\'s ISO 4217 minor unit decides how the amount displays'); + expect(description).not.toContain('currencyConfig'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// The offer half: no registered metadata form shows `scale` on a currency field. +// +// The predicate reader below is the fail-closed mirror of the metadata-admin +// predicate subset that `form-delete-behavior-options.test.ts` uses: it knows +// only the `data.type` spellings these forms write (`==`, `in [...]`, joined by +// `||`) and THROWS on anything else, so a row whose predicate it cannot read +// fails here instead of being assumed visible or hidden. +// ──────────────────────────────────────────────────────────────────────────── + +type FormRow = Record; + +/** `defineForm` stores a predicate as `{ dialect, source }`; accept the bare string too. */ +function predicateSource(row: FormRow): string | undefined { + const raw = row.visibleWhen; + if (raw == null) return undefined; + if (typeof raw === 'string') return raw; + if (typeof raw === 'object' && typeof (raw as { source?: unknown }).source === 'string') { + return (raw as { source: string }).source; + } + throw new Error(`unreadable visibleWhen on '${String(row.field)}': ${JSON.stringify(raw)}`); +} + +/** Whether the row renders for a field of `type`. Throws on an unrecognised clause. */ +function isOfferedForType(row: FormRow, type: string): boolean { + const src = predicateSource(row); + if (src === undefined) return true; + return src.split('||').some((part) => { + const clause = part.trim(); + const eq = /^data\.type\s*==\s*'([^']*)'$/.exec(clause); + if (eq) return eq[1] === type; + const inList = /^data\.type\s+in\s+\[([^\]]*)\]$/.exec(clause); + if (inList) { + return inList[1].split(',').map((m) => { + const lit = /^'([^']*)'$/.exec(m.trim()); + if (!lit) throw new Error(`non-literal member in \`in\` list: ${m}`); + return lit[1]; + }).includes(type); + } + throw new Error(`predicate spelling not covered by this reader: ${JSON.stringify(clause)}`); + }); +} + +/** Every row named `key` in a form, at any depth (sections, repeater rows), by dotted path. */ +function rowsNamed(form: unknown, key: string): Array<{ path: string; row: FormRow }> { + const out: Array<{ path: string; row: FormRow }> = []; + const walk = (rows: unknown, prefix: string) => { + if (!Array.isArray(rows)) return; + for (const row of rows as FormRow[]) { + if (typeof row?.field !== 'string') continue; + const path = prefix ? `${prefix}.${row.field}` : row.field; + if (row.field === key) out.push({ path, row }); + walk(row.fields, path); + } + }; + for (const section of ((form as { sections?: Array<{ fields?: unknown }> })?.sections ?? [])) { + walk(section.fields, ''); + } + return out; +} + +describe('#19629 — no registered metadata form OFFERS `scale` on a currency field', () => { + it('CONTROLS: the walk reaches exactly the two `scale` rows the registered forms declare, and the object form is the registered one', () => { + // A lit roster, so an empty result below is a measured zero rather than a + // walk that found nothing to judge; a new `scale` row anywhere turns this + // red and gets read against the ruling before it ships. + const roster = Object.entries(METADATA_FORM_REGISTRY) + .flatMap(([type, form]) => rowsNamed(form, 'scale').map((r) => `${type}:${r.path}`)) + .sort(); + expect(roster).toEqual(['field:scale', 'object:fields.scale']); + expect(METADATA_FORM_REGISTRY.object).toBe(objectForm); + // The reader is capable of saying "offered": a known row it must light up. + expect(isOfferedForType({ field: 'x', visibleWhen: "data.type in ['currency']" }, 'currency')).toBe(true); + }); + + it('the object designer\'s quick-add grid does not offer `scale` on a currency row — the door refuses it at parse', () => { + const rows = rowsNamed(objectForm, 'scale'); + expect(rows.map((r) => r.path)).toEqual(['fields.scale']); + expect(isOfferedForType(rows[0].row, 'currency')).toBe(false); + }); + + it('the object designer still offers `scale` where the key applies — number and percent', () => { + const [{ row }] = rowsNamed(objectForm, 'scale'); + for (const type of ['number', 'percent']) { + expect(isOfferedForType(row, type), type).toBe(true); + // And the door agrees: the offered key parses on that type. + expect(FieldSchema.safeParse({ name: 'n', label: 'N', type, scale: 2 }).success, type).toBe(true); + } + }); + + it('no registered form offers `scale` on a currency field', () => { + const offered = Object.entries(METADATA_FORM_REGISTRY).flatMap(([type, form]) => + rowsNamed(form, 'scale') + .filter((r) => isOfferedForType(r.row, 'currency')) + .map((r) => `${type}:${r.path}`), + ); + expect(offered).toEqual([]); + }); +}); diff --git a/packages/spec/src/data/field-scale.test.ts b/packages/spec/src/data/field-scale.test.ts index e660f509324..3969edcd690 100644 --- a/packages/spec/src/data/field-scale.test.ts +++ b/packages/spec/src/data/field-scale.test.ts @@ -30,7 +30,10 @@ describe('resolveFieldScale', () => { it('prefers a DECLARED scale over the type default', () => { expect(resolveFieldScale({ type: 'percent', scale: 2 })).toBe(2); expect(resolveFieldScale({ type: 'number', scale: 3 })).toBe(3); - expect(resolveFieldScale({ type: 'currency', scale: 4 })).toBe(4); + // `slider`, not `currency`: `scale` is retired from the currency type and + // refused at parse (ruling 5791803339, letter B), so a currency field is no + // longer a type whose declared width this resolver is asked about. + expect(resolveFieldScale({ type: 'slider', scale: 4 })).toBe(4); }); it('treats a declared 0 as a declaration, not as absence', () => { @@ -45,8 +48,8 @@ describe('resolveFieldScale', () => { // ⛔ Not a hole to fill with a fallback: `number`'s faces disagree by // design (no-fixed-width on the cell, 0 in the summary footer) and the // grouping policy keys on absent-vs-declared-0; `currency` resolves its - // fraction digits from ISO 4217 minor units with a different surface's - // `precision` as the override, and does not read this key at all. + // cell's fraction digits from ISO 4217 minor units and can no longer + // declare this key at all (retired from the type, refused at parse). expect(resolveFieldScale({ type: 'number' })).toBeUndefined(); expect(resolveFieldScale({ type: 'currency' })).toBeUndefined(); }); diff --git a/packages/spec/src/data/field-scale.ts b/packages/spec/src/data/field-scale.ts index 9a4fb39378f..db1387ce090 100644 --- a/packages/spec/src/data/field-scale.ts +++ b/packages/spec/src/data/field-scale.ts @@ -65,15 +65,24 @@ * absent `scale` means "decimals unknown" and keeps its separators. Declaring * `number ⇒ 0` would print every undeclared number field ungrouped: `2026` * where the platform shows `2,026`. - * - `currency` — the money faces do not read this key at all. They resolve - * fraction digits from the currency's own ISO 4217 minor-unit count, with - * `CurrencyConfigSchema.precision` (a DIFFERENT surface, with its own - * `scale` → `precision` alias) as the authored override. The one `2` that - * looks like a currency default belongs to an inline grid COLUMN's rounding - * of a computed result, not to a field's display width. - * - * ⛔ Neither reading is an argument for `0`, and neither is an argument for - * the other face's value: they are an argument that nobody has ruled yet. + * - `currency` — ruled since, and not by a row here: `scale` is RETIRED from + * the `currency` type (#19629, ruling 5791803339 letter B) and refused at + * parse, so a currency field declares no `scale` for this table to default. + * Ruling 5805782503 (letter 乙) words the refusal's remedy: a currency's + * decimal places are the currency's, not a setting — its ISO 4217 minor + * unit decides its display. The amount's cell resolves its fraction + * digits from the currency's own ISO 4217 minor-unit count and never read + * the key; measured at + * `.objectui-sha` pin `62597c588072` (the pin when the ruling landed), the + * grid summary footer and the dashboard metric widget did read it on a + * currency column, as `scale ?? 0` — the ruling's consumer half is + * objectui#10221, which ruling 乙 widens to that metric widget and lands + * first. The one `2` that looks like a currency default belongs to an + * inline grid COLUMN's rounding of a computed result, not to a field's + * display width. + * + * ⛔ Neither reading is an argument for a `0` row: `number` still awaits its + * own ruling, and `currency` no longer has a declaration to resolve. * * ## Why the table is not exported * diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index c7fbdee623e..06d6d7dbe4a 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -282,10 +282,13 @@ describe('FieldSchema', () => { describe('Number Field Constraints', () => { it('should accept number field with precision and scale', () => { + // `type: 'number'`, as the title says: this fixture spelled `currency` + // until `scale` was retired from the currency type (refused at parse — + // pinned in field-currency-scale-refused.test.ts). const numberField: Field = { name: 'amount', label: 'Amount', - type: 'currency', + type: 'number', precision: 10, scale: 2, min: 0, diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index cc962542842..d6ada97894c 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1223,12 +1223,17 @@ export const FieldSchema = lazySchema(() => { // `fraction`) holds the same quantity two places further right, so // `packages/objectql`'s `max_scale` branch allows it `scale + 2`; a // whole-percent field (`max` above 1) stores the displayed number itself and - // is allowed exactly `scale`, as is every other numeric type. + // is allowed exactly `scale`, as are `number`, `rating` and `slider`. // ⛔ Both halves belong in the `.describe()` and not only in this comment: // the field reference page is generated from the describe, and an author who // reads only that page is exactly the author the ruling is about. + // #19629 — and on ONE type the key is not authorable at all: ruling + // 5791803339 (batch #215 item 1, letter B) retires `scale` from `currency`, + // refused in the superRefine below, with the remedy ruling 5805782503 + // (batch #218 item 2, letter 乙) words. The describe names the type set the + // key still applies to, for the same reason as above. scale: z.number().int().min(0).max(MAX_RENDERABLE_SCALE, { message: SCALE_UPPER_BOUND_MESSAGE }).optional() - .describe('Decimal places (integer 0-100). OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field\'s storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform\'s, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`\'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer.'), + .describe('Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount\'s decimal places are its currency\'s, so the currency\'s ISO 4217 minor unit decides how the amount displays, and a currency write\'s decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field\'s storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform\'s, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`\'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer.'), min: z.number().optional().describe('Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead.'), max: z.number().optional().describe('Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead.'), /** @@ -2252,6 +2257,41 @@ export const FieldSchema = lazySchema(() => { }); } + // #19629 (maintainer ruling 5791803339 — batch #215 item 1, letter B, + // 「215 同意」): `scale` is RETIRED from the `currency` type and refused at + // this seam. On a currency field the key was three-faced: the field + // designer offered it, the amount's cell never read it (the cell's fraction + // digits are the currency's own ISO 4217 minor-unit count), and + // `packages/objectql`'s `max_scale` branch still refused writes carrying + // more decimals — so an author who set it bought a narrower write contract + // and no visible change. The ruling retires the key rather than aligning the + // money faces to it. The remedy is worded by ruling 5805782503 (batch #218 + // item 2, letter 乙 — a currency's decimal places are the currency's, not a + // setting): delete the key; the currency's ISO 4217 minor unit decides its + // display, and its write allowance stays unconstrained (today's contract + // for a currency field that declares no `scale`). ⛔ The remedy names no + // other key to carry the value: nothing replaces it. ⛔ No alias and no + // grace window (the first ruling's own words: retirement is immediate), and + // the write seam drops `currency` from its enforced set in the same change, + // so no declaration anywhere keeps the old narrowing alive. `scale` has no + // schema default, so `undefined` here always means "not authored" — a + // currency field without the key can never fire this, and `parse(parse(x))` + // stays stable. Stored metadata carrying the key is covered by the ADR-0087 + // semantic entry `field-currency-scale-refused`. + if (field.type === 'currency' && field.scale !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['scale'], + message: + '`scale` is not valid on a `currency` field — delete the key. A currency amount\'s decimal ' + + 'places are its currency\'s, not a field setting: the currency\'s ISO 4217 minor unit (2 for ' + + 'USD, 0 for JPY, 3 for KWD) decides how the amount displays, and the field\'s write allowance ' + + 'stays unconstrained — a currency write is accepted with the decimals it carries, as it always ' + + 'was on a currency field that declared no `scale`. The key\'s one enforced effect was refusing ' + + 'writes with more decimals, which this field type no longer does.', + }); + } + // #7918 (maintainer ruling 2026-08-12, Option A): the FIELD-level // `precision` key doubles as the currency display width — objectui's // CurrencyField reads it, and objectui#4361 pinned authored-precision-wins diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 38a494a2c53..6e87b93283f 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -187,7 +187,8 @@ export const objectForm = defineForm({ { field: 'min', type: 'number', helpText: 'Minimum value', visibleWhen: "data.type in ['number','currency','percent','rating','slider','progress']" }, { field: 'max', type: 'number', helpText: 'Maximum value', visibleWhen: "data.type in ['number','currency','percent','rating','slider','progress']" }, { field: 'precision', type: 'number', helpText: 'Total digits', visibleWhen: "data.type in ['number','currency','percent']" }, - { field: 'scale', type: 'number', helpText: 'Decimal places', visibleWhen: "data.type in ['number','currency','percent']" }, + // #19629 (ruling 5791803339 B): `scale` is retired from `currency` and refused at parse, so it is not offered there. + { field: 'scale', type: 'number', helpText: 'Decimal places', visibleWhen: "data.type in ['number','percent']" }, // Selection options // diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 3ffdd05a05e..c4ee4371ef2 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -801,7 +801,6 @@ describe('ObjectSchema', () => { label: 'Annual Revenue', type: 'currency', precision: 18, - scale: 2, }, owner_id: { label: 'Account Owner', diff --git a/packages/spec/src/migrations/entries/semantic/18.field-currency-scale-refused.ts b/packages/spec/src/migrations/entries/semantic/18.field-currency-scale-refused.ts new file mode 100644 index 00000000000..d37aafea0d5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.field-currency-scale-refused.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'field-currency-scale-refused', + surface: 'object.fields..scale on a field whose `type` is `currency` — any declared value, ' + + '`scale: 0` included; the `Field.currency` helper passes it through unchanged. `scale` on ' + + '`number`, `percent`, `rating`, `slider` and `formula` is untouched', + replacement: 'no `scale` on a currency field. DELETE the key — that is the whole migration: a ' + + 'currency amount\'s decimal places are its currency\'s, not a field setting. The currency\'s ' + + 'ISO 4217 minor unit decides how the amount displays, and the field\'s write allowance stays ' + + 'unconstrained — a currency write is accepted with the decimals it carries, as it always was ' + + 'on a currency field that declared no `scale`. ⛔ Nothing replaces the key: do not re-declare ' + + 'its value under any other key.', + reason: + 'Maintainer ruling 5791803339 (batch #215 item 1, letter B) retires `scale` from the ' + + '`currency` field type, and ruling 5805782503 (batch #218 item 2, letter 乙 — a currency\'s ' + + 'decimal places are the currency\'s, not a setting) words the remedy. On a currency field the ' + + 'key was three-faced: the metadata-admin field designer offered it as stored metadata, the ' + + 'amount\'s cell never read it (fraction digits come from the currency\'s ISO 4217 minor ' + + 'unit), and the record validator\'s `max_scale` branch still refused writes carrying more ' + + 'decimals — so an author who set it bought a narrower write contract and no visible change. ' + + '`FieldSchema` now refuses the key on `currency` at parse, and the validator stops reading it ' + + 'for the type in the same release, so a stored declaration narrows nothing either. ⛔ No ' + + 'alias and no grace window, per the ruling. NOT mechanically converted, deliberately: a ' + + 'conversion that dropped the key would accept it on every load, which is the grace window ' + + 'the ruling refused; the refusal names the key and its one-line fix instead. Two behaviour ' + + 'changes ride along and are part of what an upgrade means: (1) a currency write with more ' + + 'decimals than a former `scale` is now ACCEPTED — the write allowance stays unconstrained, ' + + 'the contract every currency field without `scale` already had; (2) at the console pin ' + + 'measured when this was written, the grid summary footer and the dashboard metric widget ' + + 'read a currency column\'s `scale ?? 0`, and the ruling lands this change only after the ' + + 'console derives both faces from the currency, the way the cell does, and the pin has moved ' + + 'past that change. Population measured at the change, on origin/main 1f89ba0d70 by AST ' + + 'sweep: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 ' + + 'documentation examples carried `scale`, every one of them `scale: 2`; all were deleted in ' + + 'the same change.', + acceptanceCriteria: + 'Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no ' + + 'issue on a `scale` path of a `currency` field. A currency field that carried `scale` no ' + + 'longer declares it, and a diff of the field shows that one line deleted and no key added. ' + + 'Its amount\'s cell renders the currency\'s ISO 4217 minor-unit digits, as before, and a ' + + 'write with more decimals than the old `scale` is accepted where it was refused with ' + + '`max_scale`; `number` / `percent` / `rating` / `slider` fields keep their `scale` and ' + + 'still refuse over-scale writes.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 30cf33582d0..d34cd7bd05f 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8330,6 +8330,49 @@ const step18: MigrationStep = { + 'legacy branch index only when the record predates the engine build that ' + 'writes `branch`.', }, + { + id: 'field-currency-scale-refused', + surface: 'object.fields..scale on a field whose `type` is `currency` — any declared value, ' + + '`scale: 0` included; the `Field.currency` helper passes it through unchanged. `scale` on ' + + '`number`, `percent`, `rating`, `slider` and `formula` is untouched', + replacement: 'no `scale` on a currency field. DELETE the key — that is the whole migration: a ' + + 'currency amount\'s decimal places are its currency\'s, not a field setting. The currency\'s ' + + 'ISO 4217 minor unit decides how the amount displays, and the field\'s write allowance stays ' + + 'unconstrained — a currency write is accepted with the decimals it carries, as it always was ' + + 'on a currency field that declared no `scale`. ⛔ Nothing replaces the key: do not re-declare ' + + 'its value under any other key.', + reason: + 'Maintainer ruling 5791803339 (batch #215 item 1, letter B) retires `scale` from the ' + + '`currency` field type, and ruling 5805782503 (batch #218 item 2, letter 乙 — a currency\'s ' + + 'decimal places are the currency\'s, not a setting) words the remedy. On a currency field the ' + + 'key was three-faced: the metadata-admin field designer offered it as stored metadata, the ' + + 'amount\'s cell never read it (fraction digits come from the currency\'s ISO 4217 minor ' + + 'unit), and the record validator\'s `max_scale` branch still refused writes carrying more ' + + 'decimals — so an author who set it bought a narrower write contract and no visible change. ' + + '`FieldSchema` now refuses the key on `currency` at parse, and the validator stops reading it ' + + 'for the type in the same release, so a stored declaration narrows nothing either. ⛔ No ' + + 'alias and no grace window, per the ruling. NOT mechanically converted, deliberately: a ' + + 'conversion that dropped the key would accept it on every load, which is the grace window ' + + 'the ruling refused; the refusal names the key and its one-line fix instead. Two behaviour ' + + 'changes ride along and are part of what an upgrade means: (1) a currency write with more ' + + 'decimals than a former `scale` is now ACCEPTED — the write allowance stays unconstrained, ' + + 'the contract every currency field without `scale` already had; (2) at the console pin ' + + 'measured when this was written, the grid summary footer and the dashboard metric widget ' + + 'read a currency column\'s `scale ?? 0`, and the ruling lands this change only after the ' + + 'console derives both faces from the currency, the way the cell does, and the pin has moved ' + + 'past that change. Population measured at the change, on origin/main 1f89ba0d70 by AST ' + + 'sweep: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 ' + + 'documentation examples carried `scale`, every one of them `scale: 2`; all were deleted in ' + + 'the same change.', + acceptanceCriteria: + 'Every field in the stack parses: `ObjectSchema.parse()` / `objectstack validate` report no ' + + 'issue on a `scale` path of a `currency` field. A currency field that carried `scale` no ' + + 'longer declares it, and a diff of the field shows that one line deleted and no key added. ' + + 'Its amount\'s cell renders the currency\'s ISO 4217 minor-unit digits, as before, and a ' + + 'write with more decimals than the old `scale` is accepted where it was refused with ' + + '`max_scale`; `number` / `percent` / `rating` / `slider` fields keep their `scale` and ' + + 'still refuse over-scale writes.', + }, { id: 'field-master-detail-set-null-refused', surface: "object field `deleteBehavior: 'set_null'` authored on a `master_detail` field",