diff --git a/.changeset/19992-field-precision-write-seam.md b/.changeset/19992-field-precision-write-seam.md new file mode 100644 index 00000000000..6ef72453c17 --- /dev/null +++ b/.changeset/19992-field-precision-write-seam.md @@ -0,0 +1,29 @@ +--- +"@objectstack/objectql": minor +"@objectstack/spec": minor +--- + +feat(objectql,spec)!: a numeric field's declared `precision` ("Total digits") is enforced on writes — a value that needs more digits is refused with field code `max_precision` (#19992) + +Clause-②: yes + +**BREAKING** — a narrowing of the write accept set on `@objectstack/objectql`, shipped as `minor` under the repo's launch-window convention (`check-changeset-no-major` refuses `major` until GA); the breaking-ness is carried by this banner and the ADR-0087 disposition, never by the level. Nothing an author writes changes spelling: `precision` keeps its key, its type and its legality. + +`FieldSchema.precision` was declared ("Total digits") and read by nothing. Every numeric column is the fixed exact decimal of `NUMERIC_COLUMN_REPRESENTATION`, the record validator had no branch for it, and the renderer reads the liveness ledger cited are gone, so `precision: 5` on a `number` stored `123456789` verbatim. The metadata designer writes the key (labelled Precision, beside Scale), so it was a setting an author could make and see nothing come of. It is now enforced at the one place a write is judged. + +**`@objectstack/objectql`** — the record validator refuses, after `min` / `max` and `max_scale`, a `number`, `currency`, `percent`, `rating` or `slider` value whose digit count exceeds a declared `precision`. It refuses with `400 VALIDATION_FAILED` and the field code `max_precision`, and it never rounds. The count is the SQL `DECIMAL(p, s)` one, taken on the stored value: + +- **With a `scale`**, digits are counted at the field's decimal places, so the integer part may carry `precision − scale` digits. `precision: 5, scale: 2` holds up to `999.99` and refuses `1234.5`, which is `1234.50`, six digits. +- **With no `scale`**, the value's own digits count. Leading zeros never count, and trailing zeros of the integer part always do: under `precision: 4`, `0.001` fits and `10000` does not. +- **On `currency`**, where `scale` is refused, an amount counts at its own decimals. The decimals themselves stay unconstrained, and only the total is bounded: `precision: 18` refuses a 19-digit amount. +- **On a fraction-stored `percent`** the count is taken two places further right (`scale + 2`, or 2 with no `scale`). The count is then the percentage-point value's digits as displayed: `precision: 4, scale: 2` holds 99.99% and refuses 100%. + +What an author with an oversize value sees: the write is refused, nothing is stored, and the field error names the declaration and the count. For example, `constraint: { precision: 5, scale: 2, actual: 6 }` renders as "Hourly rate must have at most 5 digits in total, counting 2 decimal places (got 6)" in four locales. The REST create, batch, update and import routes all answer it, and `validate` (the dry run) predicts it. Only NEW writes are judged: a stored value longer than a `precision` declared later is never re-read. Nothing changes in storage or DDL. + +The fix is one of three. Write a value that fits. Raise `precision` to the digits the field really holds. Or delete the key if the number was meant as decimal places: those are `scale`, and a currency's decimal places are its ISO 4217 minor unit. + +**`@objectstack/spec`** — `FieldErrorCode` (the ADR-0114 field-level catalog) gains `max_precision` beside `max_scale`. `BUILTIN_VALIDATION_MESSAGES` gains its two sentences, `max_precision` and `max_precision_scaled`, in `en` / `zh-CN` / `ja-JP` / `es-ES`. `FieldSchema.precision`'s describe now states the counting rule and where it is enforced. The `precision` row of the field liveness ledger is re-evidenced at the write seam. + +**Who is affected, measured** on `origin/main` `df3ba164`: no example app, template, platform object, seed or JSON fixture in the tree declares a field-level `precision`. Two test fixtures do (`precision: 5, scale: 0` on a 1–12 hours field), and every value they write fits. + + diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index 35342575afb..6c0225ce426 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -801,7 +801,7 @@ snake_case, so the code and the schema property are the same word. |:---|:---| | Presence and shape | `required`, `invalid_type`, `invalid_shape`, `unknown_field` | | Per-type parse | `invalid_boolean`, `invalid_number`, `invalid_date`, `invalid_time`, `invalid_email`, `invalid_url`, `invalid_phone`, `invalid_json`, `invalid_format` | -| Bounded ranges | `min_length`, `max_length`, `min_value`, `max_value`, `max_scale`, `min_items`, `max_items` | +| Bounded ranges | `min_length`, `max_length`, `min_value`, `max_value`, `max_scale`, `max_precision`, `min_items`, `max_items` | | Closed sets and references | `invalid_option`, `value_domain` (the written value is not a member of the field's declared `valueDomain` standard), `invalid_value`, `reference_not_found`, `reference_ambiguous` | | Declarative rules | `rule_violation`, `json_schema_violation`, `invalid_initial_state`, `invalid_transition` | diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index df658e50cdd..b86b1780e44 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -259,7 +259,7 @@ quantity: **Configuration:** - `scale`: Decimal places (0 = integer) -- `precision`: Total digits +- `precision`: Total digits — a write needing more, counted at `scale`, is refused (`max_precision`) - `min`/`max`: Range validation **Database mapping:** @@ -318,7 +318,10 @@ deprecated in the spec. - `currencyMode: fixed | dynamic` and a `defaultCurrency` code on the field - Codes are validated by **length only** (3 characters), so ISO 4217 (`USD`, `EUR`, `CNY`) and non-ISO codes (`BTC`, `ETH`) both pass -- `precision` (0–10, default 2) for decimal places +- No decimal-places setting: an amount's decimal places are its currency's + ISO 4217 minor unit. The field-level `precision` is the amount's **total** + digit count (a DECIMAL(18,2) amount declares `precision: 18`), refused on + write when exceeded, with the amount's written decimals counted in it **Database mapping:** - SQL driver: the same **exact decimal** column as `number` diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index f62adbe9aa9..cc6936aa3cd 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -185,7 +185,7 @@ const result = EnhancedApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field path (supports dot notation) | -| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| … +20 more>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) | +| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| … +21 more>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) | | **message** | `string` | ✅ | Human-readable error message, rendered in the caller’s locale | | **label** | `string` | optional | Field display label in the caller’s locale | | **value** | `any` | optional | The invalid value that was provided | @@ -211,6 +211,7 @@ const result = EnhancedApiErrorSchema.parse(data); * `min_value` * `max_value` * `max_scale` +* `max_precision` * `min_items` * `max_items` * `invalid_option` @@ -248,6 +249,7 @@ const result = EnhancedApiErrorSchema.parse(data); * `min_value` * `max_value` * `max_scale` +* `max_precision` * `min_items` * `max_items` * `invalid_option` diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index a730dd7662f..335ff4b77c6 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -66,7 +66,7 @@ const result = CurrencyConfigSchema.parse(data); | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. | | **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) | +| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | | **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. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index f17f4d4aaef..7b2dbc72690 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -229,7 +229,7 @@ const result = ApiMethod.parse(data); | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. | | **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) | +| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | | **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. | @@ -561,7 +561,7 @@ const result = ApiMethod.parse(data); | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. | | **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) | +| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | | **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. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 3f3b3486191..5c0256f2652 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -67,7 +67,7 @@ Add a new field to an existing object | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. | | **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) | +| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | | **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. | @@ -487,7 +487,7 @@ Add a new field to an existing object | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. | | **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) | +| **precision** | `integer` | optional | Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value's own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount's written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. | | **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. | diff --git a/packages/objectql/src/validation/record-validator.precision.test.ts b/packages/objectql/src/validation/record-validator.precision.test.ts new file mode 100644 index 00000000000..b214a427527 --- /dev/null +++ b/packages/objectql/src/validation/record-validator.precision.test.ts @@ -0,0 +1,355 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { validateRecord, ValidationError } from './record-validator.js'; +import { ObjectQL } from '../engine.js'; + +/** + * #19992 — a numeric field's declared `precision` ("Total digits") is ENFORCED + * at the write seam, by rejection (`max_precision`), never by rounding. + * + * Before this, nothing read the key: `numeric-column-representation.ts` gives + * every numeric column the fixed `(65, 30)` exact decimal, the record validator + * had no branch for it, and the objectui reads the liveness ledger cited were + * retired — so `precision: 5` on a `number` stored `123456789` verbatim. Triage + * answered the enforce-or-remove question ENFORCE by the maintainer's #18900 ④ + * criterion (a total-digit bound is mainstream: SQL `DECIMAL(p, s)`, Salesforce + * Length + Decimal Places), at the write seam and ⛔ not in storage. + * + * The reading pinned here is DECIMAL(p, s): the value's digits are counted at + * the decimal places the `scale` rule applies, so the integer part may carry + * `precision − scale` digits. The three triage pins come first; the rest pin + * how an undeclared `scale` counts, what the fraction-stored `percent` basis + * does to the count, and the boundaries of the rule. + */ + +const fieldsOf = ( + schema: Parameters[0], + data: Record, + mode: 'insert' | 'update' = 'insert', + options: Parameters[3] = {}, +) => { + try { + validateRecord(schema, data, mode, options); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + return (e as ValidationError).fields; + } + return null; +}; + +describe('validateRecord — `precision` is enforced by rejection (#19992): the triage pins', () => { + it('a `number` with `precision: 5, scale: 2` refuses 1234.5 and accepts 123.45', () => { + const s = { fields: { rate: { type: 'number', label: 'Rate', precision: 5, scale: 2 } } }; + const errs = fieldsOf(s, { rate: 1234.5 }); + expect(errs).toHaveLength(1); + expect(errs![0]).toMatchObject({ + field: 'rate', + code: 'max_precision', + // 1234.5 at the field's 2 decimal places is 1234.50: six digits. + constraint: { precision: 5, scale: 2, actual: 6 }, + }); + expect(errs![0].message).toBe('Rate must have at most 5 digits in total, counting 2 decimal places (got 6)'); + // The thrown error is the VALIDATION_FAILED envelope REST maps to 400. + try { + validateRecord(s, { rate: 1234.5 }, 'insert'); + throw new Error('expected a ValidationError'); + } catch (e) { + expect((e as ValidationError).code).toBe('VALIDATION_FAILED'); + } + expect(fieldsOf(s, { rate: 123.45 })).toBeNull(); + }); + + it('a `currency` with `precision: 18` refuses a 19-digit amount', () => { + const s = { fields: { amount: { type: 'currency', label: 'Amount', precision: 18 } } }; + // 10^18 is 1 followed by 18 zeros — 19 digits, exact in a double. + const errs = fieldsOf(s, { amount: 1e18 }); + expect(errs?.[0]).toMatchObject({ + field: 'amount', + code: 'max_precision', + constraint: { precision: 18, scale: 0, actual: 19 }, + }); + expect(errs![0].message).toBe('Amount must have at most 18 digits in total (got 19)'); + // An 18-digit amount fits. + expect(fieldsOf(s, { amount: 1e17 })).toBeNull(); + }); + + it('CONTROL — an undeclared `precision` accepts both values', () => { + const number = { fields: { rate: { type: 'number', label: 'Rate', scale: 2 } } }; + expect(fieldsOf(number, { rate: 1234.5 })).toBeNull(); + const currency = { fields: { amount: { type: 'currency', label: 'Amount' } } }; + expect(fieldsOf(currency, { amount: 1e18 })).toBeNull(); + }); +}); + +describe('validateRecord — the DECIMAL(p, s) reading of `precision` (#19992)', () => { + const schema = { + fields: { + rate: { type: 'number', label: 'Rate', precision: 5, scale: 2 }, + qty: { type: 'number', label: 'Qty', precision: 4 }, + tight: { type: 'number', label: 'Tight', precision: 1, scale: 2 }, + zero: { type: 'number', label: 'Zero', precision: 0 }, + }, + }; + + it('with a declared `scale`, the integer part may carry `precision − scale` digits', () => { + for (const ok of [999.99, -999.99, 0.01, 5, 0, 123.4]) { + expect(fieldsOf(schema, { rate: ok }), String(ok)).toBeNull(); + } + // 1000 is 1000.00 at the field's scale — six digits. + expect(fieldsOf(schema, { rate: 1000 })?.[0]).toMatchObject({ + code: 'max_precision', + constraint: { precision: 5, scale: 2, actual: 6 }, + }); + expect(fieldsOf(schema, { rate: -1000 })?.[0]).toMatchObject({ code: 'max_precision' }); + }); + + it('with NO `scale`, the value counts at its own decimal places — and leading zeros never count', () => { + for (const ok of [1234, 12.34, 1.234, 0.001, 0.1234, -1234]) { + expect(fieldsOf(schema, { qty: ok }), String(ok)).toBeNull(); + } + const [err] = fieldsOf(schema, { qty: 12345 })!; + expect(err).toMatchObject({ field: 'qty', code: 'max_precision', constraint: { precision: 4, scale: 0, actual: 5 } }); + // The value's own digits were counted, so the plain sentence — no padding to explain. + expect(err.message).toBe('Qty must have at most 4 digits in total (got 5)'); + expect(fieldsOf(schema, { qty: 1.2345 })?.[0]).toMatchObject({ + field: 'qty', + code: 'max_precision', + constraint: { precision: 4, scale: 4, actual: 5 }, + }); + // A trailing zero of the INTEGER part is a digit: 10000 needs five. + expect(fieldsOf(schema, { qty: 10000 })?.[0]).toMatchObject({ constraint: { actual: 5 } }); + }); + + it('exponent forms are normalized, as the `scale` count normalizes them', () => { + // 1.5e-7 is 0.00000015: two significant digits, no leading zero counted. + expect(fieldsOf(schema, { qty: 1.5e-7 })).toBeNull(); + // 1.23e+21 is a 22-digit integer. + expect(fieldsOf(schema, { qty: 1.23e21 })?.[0]).toMatchObject({ constraint: { precision: 4, actual: 22 } }); + }); + + it('`precision` below `scale` keeps a meaning (the DECIMAL range |v| < 10^(p − s)): 0.05 fits `precision: 1, scale: 2`, 0.1 does not', () => { + expect(fieldsOf(schema, { tight: 0.05 })).toBeNull(); + expect(fieldsOf(schema, { tight: 0.1 })?.[0]).toMatchObject({ + code: 'max_precision', + constraint: { precision: 1, scale: 2, actual: 2 }, + }); + }); + + it('zero occupies no digits, so it fits even `precision: 0` — which refuses every other value', () => { + expect(fieldsOf(schema, { zero: 0 })).toBeNull(); + expect(fieldsOf(schema, { zero: 1 })?.[0]).toMatchObject({ field: 'zero', code: 'max_precision', constraint: { precision: 0, actual: 1 } }); + }); +}); + +describe('validateRecord — `precision` on `currency` and `percent` (#19992)', () => { + it('currency: `scale` is refused there, so an amount counts at its own decimals — which stay unconstrained, only the total is bounded', () => { + const s = { fields: { amount: { type: 'currency', label: 'Amount', precision: 10 } } }; + expect(fieldsOf(s, { amount: 12345678.12 })).toBeNull(); // 10 digits + expect(fieldsOf(s, { amount: 1.23456789 })).toBeNull(); // 9 digits, 8 of them decimals — no max_scale on currency + expect(fieldsOf(s, { amount: 123456789.12 })?.[0]).toMatchObject({ + field: 'amount', + code: 'max_precision', + constraint: { precision: 10, scale: 2, actual: 11 }, + }); + // A legacy `scale` on a currency def (it bypassed FieldSchema) narrows + // nothing (#19629), so it does not pad the count either. + const legacy = { fields: { amount: { type: 'currency', label: 'Amount', precision: 3, scale: 2 } } }; + expect(fieldsOf(legacy, { amount: 123 })).toBeNull(); + }); + + it('a fraction-stored percent with `scale` counts at `scale + 2` — the percentage-point digits as displayed', () => { + // precision 4, scale 2: displayed up to 99.99%, stored up to 0.9999. + const s = { fields: { p: { type: 'percent', label: 'P', precision: 4, scale: 2 } } }; + expect(fieldsOf(s, { p: 0.9999 })).toBeNull(); + expect(fieldsOf(s, { p: 0.05 })).toBeNull(); + // 100.00% is stored 1, counted 1.0000: five digits. + const [err] = fieldsOf(s, { p: 1 })!; + expect(err).toMatchObject({ field: 'p', code: 'max_precision', constraint: { precision: 4, scale: 4, actual: 5 } }); + expect(err.message).toBe('P must have at most 4 digits in total, counting 4 decimal places (got 5)'); + }); + + it('a fraction-stored percent with NO `scale` is still counted two places right — so 1000% (stored 10) is four digits', () => { + const s = { fields: { p: { type: 'percent', label: 'P', precision: 3 } } }; + expect(fieldsOf(s, { p: 9.99 })).toBeNull(); // 999% + expect(fieldsOf(s, { p: 0.123 })).toBeNull(); // 12.3% + expect(fieldsOf(s, { p: 10 })?.[0]).toMatchObject({ code: 'max_precision', constraint: { precision: 3, scale: 2, actual: 4 } }); + // 12.345% is stored 0.12345: its own five decimals are counted, unpadded. + expect(fieldsOf(s, { p: 0.12345 })?.[0]).toMatchObject({ constraint: { precision: 3, scale: 5, actual: 5 } }); + }); + + it('a whole-percent field (`max` above 1) stores the displayed number, so it counts like a `number`', () => { + const s = { fields: { p: { type: 'percent', label: 'P', precision: 5, scale: 2, max: 1000 } } }; + expect(fieldsOf(s, { p: 100 })).toBeNull(); // 100.00 + expect(fieldsOf(s, { p: 999.99 })).toBeNull(); + expect(fieldsOf(s, { p: 1000 })?.[0]).toMatchObject({ code: 'max_precision', constraint: { precision: 5, scale: 2, actual: 6 } }); + }); +}); + +describe('validateRecord — where `precision` binds, and where it does not (#19992)', () => { + it('binds on number, currency, percent, rating and slider; ⛔ not on progress, whose bounds the numeric branch never reads', () => { + for (const type of ['number', 'currency', 'percent', 'rating', 'slider']) { + const s = { fields: { v: { type, label: 'V', precision: 1, ...(type === 'percent' ? { max: 100 } : {}) } } }; + expect(fieldsOf(s, { v: 12 })?.[0], type).toMatchObject({ field: 'v', code: 'max_precision' }); + } + const progress = { fields: { v: { type: 'progress', label: 'V', precision: 1 } } }; + expect(fieldsOf(progress, { v: 50 })).toBeNull(); + }); + + it('runs after `min` / `max` and `max_scale` — an over-scale value answers `max_scale`, as before', () => { + const s = { fields: { rate: { type: 'number', label: 'Rate', precision: 5, scale: 2, max: 500 } } }; + expect(fieldsOf(s, { rate: 1234.567 })?.[0]).toMatchObject({ code: 'max_value' }); + const unbounded = { fields: { rate: { type: 'number', label: 'Rate', precision: 5, scale: 2 } } }; + expect(fieldsOf(unbounded, { rate: 1234.567 })?.[0]).toMatchObject({ code: 'max_scale', constraint: { scale: 2, actual: 3 } }); + }); + + it('refuses on update too, and judges a string-carried number (a CSV cell) after coercion', () => { + const s = { fields: { rate: { type: 'number', label: 'Rate', precision: 5, scale: 2 } } }; + expect(fieldsOf(s, { rate: 1234.5 }, 'update')?.[0]).toMatchObject({ field: 'rate', code: 'max_precision' }); + expect(fieldsOf(s, { rate: '1234.5' })?.[0]).toMatchObject({ code: 'max_precision', constraint: { actual: 6 } }); + expect(fieldsOf(s, { rate: '123.45' })).toBeNull(); + // An omitted field is never judged on update — a stored value above a count declared later rests. + expect(fieldsOf(s, { other: 1 }, 'update')).toBeNull(); + }); + + it('a malformed declaration that bypassed FieldSchema stays unenforced — the runtime invents no meaning for it', () => { + const bad = { fields: { a: { type: 'number', precision: 2.5 }, b: { type: 'number', precision: -1 } } }; + expect(fieldsOf(bad, { a: 123456, b: 123456 })).toBeNull(); + }); + + it('renders the refusal fully localized', () => { + const s = { fields: { rate: { type: 'number', label: 'Rate', precision: 5, scale: 2 } } }; + const zh = fieldsOf(s, { rate: 1234.5 }, 'insert', { messages: { locale: 'zh-CN', objectName: 'x' } }); + expect(zh?.[0].message).toBe('Rate的总位数不能超过 5 位(按 2 位小数计,当前 6 位)'); + }); +}); + +// --------------------------------------------------------------------------- +// Every engine write door reaches the refusal, the bulk doors included (AGENTS.md +// Prime Directive #10: "check every call site, bulk paths included"). The stub +// driver records what it is handed, so a refused write is shown to reach +// nothing — the refusal is not a message decorating a stored row. +// --------------------------------------------------------------------------- + +function makeStubDriver() { + const calls: Array<{ fn: string; data: unknown }> = []; + const rows = new Map>(); + let n = 0; + const driver: any = { + name: 'stub', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find() { return [...rows.values()]; }, + async findOne(_o: string, q: any) { + const id = (q?.where ?? q?.filter ?? q)?.id; + return (typeof id === 'string' ? rows.get(id) : rows.values().next().value) ?? null; + }, + async count() { return rows.size; }, + async create(_o: string, data: Record) { + calls.push({ fn: 'create', data }); + const row = { ...data, id: (data.id as string) ?? `r${++n}` }; + rows.set(row.id as string, row); + return row; + }, + async bulkCreate(_o: string, list: Record[]) { + calls.push({ fn: 'bulkCreate', data: list }); + return list.map((r) => { + const row = { ...r, id: (r.id as string) ?? `r${++n}` }; + rows.set(row.id as string, row); + return row; + }); + }, + async update(_o: string, id: string, data: Record) { + calls.push({ fn: 'update', data }); + const row = { ...(rows.get(id) ?? {}), ...data, id }; + rows.set(id, row); + return row; + }, + async updateMany(_o: string, _ast: unknown, data: Record) { + calls.push({ fn: 'updateMany', data }); + return rows.size; + }, + async upsert(o: string, data: Record) { return this.create(o, data); }, + async delete() { return true; }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, calls }; +} + +const PRICED = { + name: 'priced', + label: 'Priced', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + rate: { name: 'rate', type: 'number' as const, label: 'Rate', precision: 5, scale: 2 }, + }, +}; + +describe('engine write doors — `precision` is refused on every door, bulk included (#19992)', () => { + let engine: ObjectQL; + let stub: ReturnType; + + beforeEach(async () => { + stub = makeStubDriver(); + engine = new ObjectQL(); + engine.registerDriver(stub.driver, true); + await engine.init(); + engine.registry.registerObject(PRICED as any); + }); + + const refusal = async (fn: () => Promise) => { + try { + await fn(); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + return { code: (e as ValidationError).code, fields: (e as ValidationError).fields.map((f) => [f.field, f.code]) }; + } + return null; + }; + const REFUSED = { code: 'VALIDATION_FAILED', fields: [['rate', 'max_precision']] }; + const writes = () => stub.calls.filter((c) => c.fn !== 'find'); + + it('insert of one row, and of an array of rows — the whole batch is refused and nothing reaches the driver', async () => { + expect(await refusal(() => engine.insert('priced', { id: 'a', rate: 1234.5 }))).toEqual(REFUSED); + expect( + await refusal(() => engine.insert('priced', [{ id: 'b1', rate: 123.45 }, { id: 'b2', rate: 1234.5 }])), + ).toEqual(REFUSED); + expect(writes()).toEqual([]); + }); + + it('insertMany (partial success): the over-precision row fails alone, the fitting row is written', async () => { + const outcomes = await engine.insertMany('priced', [{ id: 'm1', rate: 123.45 }, { id: 'm2', rate: 1234.5 }]); + expect(outcomes.map((o) => o.ok)).toEqual([true, false]); + const failed = outcomes[1] as { ok: false; error: unknown }; + expect(failed.error).toBeInstanceOf(ValidationError); + expect((failed.error as ValidationError).fields.map((f) => [f.field, f.code])).toEqual([['rate', 'max_precision']]); + const written = writes().flatMap((c) => (c.fn === 'bulkCreate' ? (c.data as any[]) : [c.data])); + expect(written.map((r: any) => r.id)).toEqual(['m1']); + }); + + it('update by id and update by predicate (multi) — refused before the driver', async () => { + await engine.insert('priced', { id: 'u1', rate: 1 }); + stub.calls.length = 0; + expect(await refusal(() => engine.update('priced', { id: 'u1', rate: 1234.5 }))).toEqual(REFUSED); + expect( + await refusal(() => engine.update('priced', { rate: 1234.5 }, { where: { id: { $in: ['u1'] } }, multi: true } as any)), + ).toEqual(REFUSED); + expect(writes().filter((c) => c.fn === 'update' || c.fn === 'updateMany')).toEqual([]); + }); + + it('the dry run (`validate`) predicts the same refusal', async () => { + const refused = await engine.validate('priced', { id: 'p1', rate: 1234.5 }); + expect(refused.valid).toBe(false); + expect(refused.results?.[0]?.errors.map((e: any) => [e.field, e.code])).toEqual([['rate', 'max_precision']]); + expect((await engine.validate('priced', { id: 'p2', rate: 123.45 })).valid).toBe(true); + }); + + it('CONTROL — a fitting value passes every door', async () => { + await engine.insert('priced', { id: 'c1', rate: 999.99 }); + await engine.insert('priced', [{ id: 'c2', rate: 0.5 }]); + await engine.update('priced', { id: 'c1', rate: 123.45 }); + await engine.update('priced', { rate: 1 }, { where: { id: { $in: ['c1'] } }, multi: true } as any); + expect(writes().map((c) => c.fn)).toEqual(expect.arrayContaining(['update', 'updateMany'])); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 14e67988676..84ef8a2e764 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -41,6 +41,14 @@ * and the stored fraction carries the same quantity two * places further right (ruling batch #161 item 3 letter B, * 2026-09-18). + * - `precision` more digits in total than the field's declared count → + * `max_precision` (#19992; rejection, never rounding), on + * `number` / `currency` / `percent` / `rating` / `slider`. + * The DECIMAL(p, s) reading: the value's digits counted at + * the decimal places the `scale` rule above applies (the + * value's own, when it applies none), so `precision: 5, + * scale: 2` refuses `1234.5`. A fraction-stored `percent` is + * always counted two places further right. No column change. * - format email / url / phone (lightweight RFC-aware regex) * - select / multiselect: value must appear in `options` * - boolean / toggle: must coerce to boolean @@ -244,6 +252,11 @@ interface FieldDef { max?: number; /** Max decimal places for number types — enforced by rejection (#7501). */ scale?: number; + /** + * Max TOTAL digits for number types — the `p` of a DECIMAL(p, s), enforced + * by rejection (#19992). See {@link digitCountAt} for what is counted. + */ + precision?: number; /** * Standard value domain the WRITTEN value must be a member of (#14168) — * the same closed vocabulary and the same membership predicate a settings @@ -305,6 +318,51 @@ function decimalPlacesOf(n: number): number { return Math.max(0, fractionDigits - exponent); } +/** + * How many digits a finite number occupies when written with at least + * `minPlaces` decimal places — the count a declared `precision` bounds (#19992). + * + * The DECIMAL(p, s) reading, the one SQL and Salesforce ("Length" + "Decimal + * Places") share: `precision` counts every digit of the value, integer and + * fraction together, at the column's decimal places. So the count is the + * number of digits from the value's first non-zero digit down to its last + * decimal place, where "last decimal place" is `minPlaces` or the value's own + * last one, whichever is further right: + * + * - `1234.5` at 2 places is `1234.50` → 6; `123.45` → 5; `5` → `5.00` → 3. + * Hence `precision: 5, scale: 2` refuses `1234.5` and holds up to `999.99`. + * - at 0 places the value's own digits count: `1e18` → 19, `100` → 3 (a + * trailing zero of the INTEGER part is a digit), `0.05` → 1 (a leading zero + * never is), `1.2345` → 5. + * - zero occupies no digits, so it fits every declaration. + * + * Equivalently, a value `v` with at most `minPlaces` decimals needs more than + * `p` digits exactly when `|v| >= 10^(p - minPlaces)` — the DECIMAL(p, s) + * range — which is also what the count answers when `minPlaces` exceeds `p` + * (`precision: 1, scale: 2` holds `0.05` and refuses `0.1`), so no declaration + * the spec accepts is left without a meaning here. + * + * Measured from the canonical string form for the reasons {@link decimalPlacesOf} + * gives (no overflow, the count the client's own payload showed); exponent forms + * are normalized the same way (`1.23e+21` → 22 at 0 places, `1.5e-7` → 2). + * Callers guard `Number.isFinite` first. + */ +function digitCountAt(n: number, minPlaces: number): number { + const m = /^-?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(String(n)); + if (!m) return 0; + const fraction = m[2] ?? ''; + // The value is int(mantissa) × 10^exponent. + let exponent = (m[3] ? Number(m[3]) : 0) - fraction.length; + const mantissa = (m[1] + fraction).replace(/^0+/, ''); + if (mantissa === '') return 0; // zero + const significant = mantissa.replace(/0+$/, ''); + exponent += mantissa.length - significant.length; + // Now `significant` has no leading or trailing zero, and the value's own + // decimal places are `max(0, -exponent)`. + const places = Math.max(minPlaces, -exponent, 0); + return significant.length + exponent + places; +} + /** * What the validator needs in order to speak the caller's language (#3957). * @@ -934,6 +992,12 @@ function validateOne( // 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. + // + // `scaleAllowance` keeps the allowance this branch APPLIED (or `undefined` + // when it applies none), because the `precision` count below is taken at + // exactly those decimal places — one reading of the field's scale, never + // a second derivation of it. + let scaleAllowance: number | undefined; if ( t !== 'currency' && def.scale !== undefined && @@ -960,6 +1024,7 @@ function validateOne( const allowed = t === 'percent' && percentScaleOf(def) === 'fraction' ? def.scale + 2 : def.scale; + scaleAllowance = allowed; const actual = decimalPlacesOf(n); if (actual > allowed) { // The envelope names the allowance that was APPLIED, not the raw @@ -971,6 +1036,60 @@ function validateOne( return fail('max_scale', { scale: allowed, actual }); } } + // ── `precision` — enforced by REJECTION, never rounding (#19992) ── + // Triage on #19992 (ENFORCE, by the maintainer's #18900 ④ criterion + // 「主流平台有没有这个能力 —— 有 ⇒ 补消费端」): a total-digit bound is the + // mainstream DECIMAL(p, s) / Salesforce Length + Decimal Places, and the + // metadata designer writes it, so the declared count binds here — refused + // like `max_scale`, for the same reason: rounding is silently altering data. + // New writes only; a stored value above a count declared later rests. + // + // The count is `digitCountAt`: the value's digits from its first non-zero + // digit down to the decimal places the `scale` branch above applied — so + // `precision: 5, scale: 2` refuses `1234.5` (`1234.50`, 6 digits) and the + // integer part may carry `precision − scale` digits. With no allowance + // applied (no `scale` declared, or `currency`, whose `scale` is refused) + // the value's OWN decimal places count: a currency amount's written + // decimals are part of its total, while the decimals themselves stay + // unconstrained (ruling 乙, above) — only the total is bounded. + // + // ⛔ The one derived floor: a fraction-stored `percent` is counted at least + // two places right even with no `scale` declared — the same two-place + // shift the `scale + 2` allowance above encodes (ruling batch #161 item 3 + // letter B). With it the count is that of the PERCENTAGE-POINT value as + // displayed and entered (`1000%` is stored `10`, counted `10.00`, 4 digits), + // so a percent's `precision` means one thing whether or not `scale` is + // declared. Read from `percentScaleOf`, never re-decided from `max`. + // + // Only a well-formed declaration is enforced, for the reason given for + // `scale` above; `FieldSchema` refuses a non-integer or negative count at + // parse (#8321). ⛔ No column follows: every numeric column stays the fixed + // NUMERIC_COLUMN_REPRESENTATION exact decimal, so this seam is the whole of + // the enforcement — sizing DDL from `precision` is a migration question + // this rule does not answer. + if ( + def.precision !== undefined && + Number.isInteger(def.precision) && + def.precision >= 0 + ) { + const minPlaces = + scaleAllowance ?? (t === 'percent' && percentScaleOf(def) === 'fraction' ? 2 : 0); + const actual = digitCountAt(n, minPlaces); + if (actual > def.precision) { + // The envelope names the decimal places the count was TAKEN at, as + // `max_scale` names its applied allowance. When the field's places + // padded the value (`1234.5` counted as `1234.50`) the sentence says + // so; otherwise the plain one — a user who typed 6 digits and reads + // "got 7" with no reason given has been handed a riddle. + const own = decimalPlacesOf(n); + const counted = Math.max(minPlaces, own); + return fail( + 'max_precision', + { precision: def.precision, scale: counted, actual }, + counted > own ? 'max_precision_scaled' : 'max_precision', + ); + } + } return null; } diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts index 70d1d44d535..bbee2f0450f 100644 --- a/packages/rest/src/import-integration.test.ts +++ b/packages/rest/src/import-integration.test.ts @@ -113,6 +113,13 @@ const MEMBER = { name: 'work_hours', type: 'number' as const, label: 'Max hours per shift', precision: 5, scale: 0, min: 1, max: 12, }, + // #19992 — the triage's own `precision` pin declaration: a DECIMAL(5, 2), + // up to 999.99. Unbounded otherwise, so `precision` is the only constraint + // a refusal below can come from. + hourly_rate: { + name: 'hourly_rate', type: 'number' as const, label: 'Hourly rate', + precision: 5, scale: 2, + }, }, }; @@ -953,6 +960,73 @@ describe('import + create routes — number `scale` enforcement (#7501)', () => }); }); +// --------------------------------------------------------------------------- +// #19992 — a declared `precision` is enforced by REJECTION at the write seam, +// and the refusal survives the HTTP error envelope: the direct create route +// answers `400 VALIDATION_FAILED` with field code `max_precision` (code AND +// status), and the import route — whose create leg is a batch through +// `createManyData` — refuses the row and writes its sibling. The engine-door +// pins (insert[], insertMany, update by predicate) live beside the validator, +// in `packages/objectql/src/validation/record-validator.precision.test.ts`. +// --------------------------------------------------------------------------- +describe('import + create routes — number `precision` enforcement (#19992)', () => { + let route: any; + let engine: any; + let rest: any; + beforeEach(async () => { ({ route, engine, rest } = await boot()); }); + + it('the direct create route answers 400 VALIDATION_FAILED + max_precision (code AND status), and writes nothing', async () => { + const create = rest.getRoutes().find( + (r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object', + ); + expect(create).toBeDefined(); + const res = makeRes(); + await create.handler({ + params: { object: 'member' }, + body: { id: 'h1', member_name: 'Ada', status: 'active', hourly_rate: 1234.5 }, + } as any, res); + expect(res._status).toBe(400); + expect(res._json).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(res._json.fields[0]).toMatchObject({ + field: 'hourly_rate', code: 'max_precision', + constraint: { precision: 5, scale: 2, actual: 6 }, + }); + expect(await engine.findOne('member', { where: { id: 'h1' } })).toBeNull(); + + // …and a value inside the declaration still writes. + const ok = makeRes(); + await create.handler({ + params: { object: 'member' }, + body: { id: 'h2', member_name: 'Bo', status: 'active', hourly_rate: 123.45 }, + } as any, ok); + expect(ok._status ?? 200).toBeLessThan(400); + expect((await engine.findOne('member', { where: { id: 'h2' } }))?.hourly_rate).toBe(123.45); + }); + + it('the import route refuses the over-precision row, writes its sibling, and the dry run predicts it', async () => { + const imp = (body: any) => { + const res = makeRes(); + return route.handler({ params: { object: 'member' }, body } as any, res).then(() => res); + }; + const rows = [ + { id: 'h3', member_name: 'Cy', status: 'active', hourly_rate: 1234.5 }, + { id: 'h4', member_name: 'Di', status: 'active', hourly_rate: 999.99 }, + ]; + const dry = await imp({ format: 'json', dryRun: true, rows }); + expect(dry._json).toMatchObject({ dryRun: true, total: 2, ok: 1, errors: 1 }); + + const res = await imp({ format: 'json', rows }); + expect(res._json).toMatchObject({ total: 2, ok: 1, errors: 1, created: 1 }); + const failed = res._json.results.find((r: any) => !r.ok); + expect(failed).toMatchObject({ + row: 1, ok: false, action: 'failed', field: 'hourly_rate', code: 'max_precision', + error: 'Hourly rate must have at most 5 digits in total, counting 2 decimal places (got 6)', + }); + expect(await engine.findOne('member', { where: { id: 'h3' } })).toBeNull(); + expect((await engine.findOne('member', { where: { id: 'h4' } }))?.hourly_rate).toBe(999.99); + }); +}); + // --------------------------------------------------------------------------- // #15907 — the PRODUCER-side pin on `GET /api/v1/meta/mapping`'s per-item shape. // diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 3241234ae65..927f045cbe3 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -949,6 +949,19 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ '\'max_scale\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so ' + 'no ledger row can be owed for it (ADR-0112 D6).', }, + { + code: 'max_precision', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'max_precision\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so ' + + 'no ledger row can be owed for it (ADR-0112 D6).', + }, { code: 'value_domain', file: 'packages/objectql/src/validation/record-validator.ts', diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 4a09abb4511..666d73d17f9 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -178,10 +178,9 @@ }, "precision": { "status": "live", - "verifiedAt": "2026-08-10", - "evidenceScope": "cross-repo", - "evidence": "objectui: packages/fields/src/widgets/CurrencyField.tsx:45 reads field.precision and applies it at :51 as the Intl min/max fraction digits, at :60 to round on blur and at :90 as the input step; objectui: packages/fields/src/widgets/PercentField.tsx:13 applies it at :31 and derives the slider step from it at :62 and :75; objectui: packages/fields/src/index.tsx:662 applies it at :673 when formatting a percent cell; objectui: packages/plugin-grid/src/useColumnSummary.ts:261 uses it as the decimal count for a column summary — measured objectui @11c1e71e", - "note": "RE-CITED 2026-08-10 (#7133/#7142): the old pointer was 'NumberField.tsx:16', and that file now states the OPPOSITE in its own comment at :17 — 'Step follows `scale` (decimal places), not `precision` (total digit count)' — reading `numberField?.scale` and never `precision`. The decimal cell formatter made the same correction (packages/fields/src/index.tsx:611-616: reading `precision` there had padded a decimal(10,0) value out to '1.0000000000'). So the citation died to a deliberate fix, not to a move, and the verdict still holds elsewhere: currency and percent are where `precision` is applied. CAVEAT retained — UI display formatting only; DDL never sizes (maps to float)." + "verifiedAt": "2026-09-28", + "evidence": "packages/objectql/src/validation/record-validator.ts#validateOne (the write-path seam, after `scale`'s `max_scale`: a `number` / `currency` / `percent` / `rating` / `slider` write whose digit count exceeds a declared `precision` is refused with the ADR-0114 code `max_precision` and `constraint: { precision, scale, actual }`, never rounded); packages/objectql/src/validation/record-validator.ts#digitCountAt (the DECIMAL(p, s) count: the value's digits at the decimal places the `scale` rule applies, else its own, a fraction-stored percent two places right); packages/objectql/src/validation/record-validator.precision.test.ts (the pins: `precision: 5, scale: 2` refuses 1234.5 and accepts 123.45, a `currency` with `precision: 18` refuses a 19-digit amount, an undeclared `precision` accepts both, and every engine write door refuses — insert[], insertMany and update by predicate included); packages/rest/src/import-integration.test.ts (the same refusal through the HTTP envelope: 400 VALIDATION_FAILED + max_precision)", + "note": "RE-EVIDENCED 2026-09-28 (#19992). The 2026-08-10 row cited objectui reads (CurrencyField.tsx:45, PercentField.tsx:13, index.tsx:662, useColumnSummary.ts:261 @11c1e71e) that are all retired at the objectui pin f8a9d0fb, where the only non-test consumer is the metadata designer's WRITE (ObjectFieldInspector, labelled Precision beside Scale — the total-digit reading); with no reader left the key was declared and inert, and triage answered the enforce-or-remove question ENFORCE by the #18900 ④ criterion (SQL DECIMAL(p, s), Salesforce Length + Decimal Places). WRITTEN VALUE ONLY, the `min`/`max`/`scale` transition-gate class: a stored value above a count declared later rests. CAVEAT — validation only: DDL never sizes from it, every numeric column is the fixed NUMERIC_COLUMN_REPRESENTATION exact decimal. Not read on non-numeric types, nor on `progress` (whose bounds the numeric branch never reads)." }, "scale": { "status": "live", @@ -224,8 +223,8 @@ "valueDomain": { "status": "live", "verifiedAt": "2026-09-04", - "evidence": "packages/objectql/src/validation/record-validator.ts#validateOne (the write-path seam, beside `maxLength`'s: `if (def.valueDomain !== undefined && VALUE_DOMAIN_FIELD_TYPES.has(t) && !isValueDomainMember(def.valueDomain, s)) return fail('value_domain', { valueDomain: def.valueDomain }, ...)` \u2014 a non-member WRITTEN to a `text` field declaring a domain is refused with the ADR-0114 code `value_domain` and `constraint.valueDomain`); packages/spec/src/data/field.zod.ts#VALUE_DOMAIN_FIELD_TYPES (the parse-time applicability door: the key is accepted on `text` only and refused with a located `custom` issue at [valueDomain] on every other type — the same superRefine mechanism `maxLength` / `minLength` use); packages/spec/src/shared/value-domain.zod.ts#isValueDomainMember (the ONE membership predicate the write path calls — shared with the settings door)", - "note": "The write path enforces it since 2026-09-04 (#15161, the engine half of the maintainer ruling 2026-09-02 option A on #14168; the spec half declared the slot, the closed vocabulary, the shared predicate, the ADR-0114 catalog member and its four-locale templates). 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, and an absent/empty value is the field's `required` handling, not this check \u2014 both pinned in packages/objectql/src/validation/record-validator.value-domain.test.ts, together with the per-domain matrix (iso_3166_alpha2 admits CH and refuses ZZ; iana_time_zone admits UTC and refuses Mars/Olympus; iso_4217_currency admits CHF and refuses chf). The applicability door is one constant read by both seams \u2014 the schema refuses the key outside VALUE_DOMAIN_FIELD_TYPES at parse and the validator judges exactly that set, so the two cannot drift into two opinions (the #11875 discipline; the subset relation to BOUNDED_STRING_FIELD_TYPES, which the enforcement branch rides on, is pinned in the same file). The settings door (`service-settings/value-domains.ts`) has re-pointed onto that same predicate (#15434, the services half of the same ruling): its second copy of all three definitions is deleted and `firstRejectedDomainMember` asks `isValueDomainMember`, so a value Settings admits is the value a field admits and vice versa; what is left on that side is the door's own business (which declarations it agrees to enforce, how a multi-value carrier is walked, the fragments the env-override log line needs), and a re-added local table reddens packages/services/service-settings/src/value-domains.shared-predicate.pin.test.ts." + "evidence": "packages/objectql/src/validation/record-validator.ts#validateOne (the write-path seam, beside `maxLength`'s: `if (def.valueDomain !== undefined && VALUE_DOMAIN_FIELD_TYPES.has(t) && !isValueDomainMember(def.valueDomain, s)) return fail('value_domain', { valueDomain: def.valueDomain }, ...)` — a non-member WRITTEN to a `text` field declaring a domain is refused with the ADR-0114 code `value_domain` and `constraint.valueDomain`); packages/spec/src/data/field.zod.ts#VALUE_DOMAIN_FIELD_TYPES (the parse-time applicability door: the key is accepted on `text` only and refused with a located `custom` issue at [valueDomain] on every other type — the same superRefine mechanism `maxLength` / `minLength` use); packages/spec/src/shared/value-domain.zod.ts#isValueDomainMember (the ONE membership predicate the write path calls — shared with the settings door)", + "note": "The write path enforces it since 2026-09-04 (#15161, the engine half of the maintainer ruling 2026-09-02 option A on #14168; the spec half declared the slot, the closed vocabulary, the shared predicate, the ADR-0114 catalog member and its four-locale templates). 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, and an absent/empty value is the field's `required` handling, not this check — both pinned in packages/objectql/src/validation/record-validator.value-domain.test.ts, together with the per-domain matrix (iso_3166_alpha2 admits CH and refuses ZZ; iana_time_zone admits UTC and refuses Mars/Olympus; iso_4217_currency admits CHF and refuses chf). The applicability door is one constant read by both seams — the schema refuses the key outside VALUE_DOMAIN_FIELD_TYPES at parse and the validator judges exactly that set, so the two cannot drift into two opinions (the #11875 discipline; the subset relation to BOUNDED_STRING_FIELD_TYPES, which the enforcement branch rides on, is pinned in the same file). The settings door (`service-settings/value-domains.ts`) has re-pointed onto that same predicate (#15434, the services half of the same ruling): its second copy of all three definitions is deleted and `firstRejectedDomainMember` asks `isValueDomainMember`, so a value Settings admits is the value a field admits and vice versa; what is left on that side is the door's own business (which declarations it agrees to enforce, how a multi-value carrier is walked, the fragments the env-override log line needs), and a re-added local table reddens packages/services/service-settings/src/value-domains.shared-predicate.pin.test.ts." }, "rows": { "status": "live", diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts index 085c27940cc..8d46299ca5d 100644 --- a/packages/spec/src/api/errors.zod.ts +++ b/packages/spec/src/api/errors.zod.ts @@ -266,6 +266,11 @@ export const FieldErrorCode = z.enum([ // `scale` is an upper bound on the fractional-digit COUNT, so it joins the // max_* family the way `max_length` bounds the character count. 'max_scale', + // more digits than the field's declared `precision` allows (#19992) — + // `precision` is an upper bound on the value's TOTAL digit count (the `p` of + // a DECIMAL(p, s)), so it joins the max_* family beside `max_scale`, the + // same way `max_length` bounds the character count. + 'max_precision', 'min_items', 'max_items', // closed sets and references diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index f92a37f50b2..3c9b421ad9e 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1234,7 +1234,15 @@ export const FieldSchema = lazySchema(() => { // (#19992) and its `decimals` / `scale` spellings are refused with the same // prescription — see CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE. Do not // conflate this total-digit count with a currency's decimal places. - precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer)'), + // #19992 (triage ENFORCE on the #18900 ④ criterion, SQL DECIMAL(p, s) / + // Salesforce Length + Decimal Places) — the count is ENFORCED at the + // write seam: `packages/objectql`'s record validator refuses a value whose + // digit count exceeds it (`max_precision`), after the `max_scale` branch and + // on the same stored-value basis. ⛔ Not a column size: every numeric column + // stays the fixed exact decimal of NUMERIC_COLUMN_REPRESENTATION. The + // describe states the counting rule because the field reference page is + // generated from it, and that page is what an author reads. + precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer) — the `p` of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field\'s decimal places, so `precision: 5, scale: 2` holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of `number`, `currency`, `percent`, `rating` and `slider` fields: a value that needs more digits is refused with field code `max_precision`, never rounded. Counted on the STORED value: at the declared `scale` when one applies, else at the value\'s own decimal places (leading zeros never count) — so on a `currency` field, where `scale` is refused, an amount\'s written decimals count toward the total; a fraction-stored `percent` is counted two places further right (`scale + 2`, or 2 with no `scale`), which makes the count that of the percentage-point value as displayed. Not decimal places (that is `scale`; a currency\'s are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform\'s fixed exact decimal whatever this declares. Not read on any other field type.'), // #18972 — and an UPPER bound, for the same declared=enforced reason one // axis over: `scale` is unrenderable above 100 at every consumer, so a // larger declaration could only ever crash a reader. See diff --git a/packages/spec/src/system/validation-message.test.ts b/packages/spec/src/system/validation-message.test.ts index d60979a0450..fd2f7cb066c 100644 --- a/packages/spec/src/system/validation-message.test.ts +++ b/packages/spec/src/system/validation-message.test.ts @@ -56,6 +56,10 @@ describe('validation message catalog — completeness', () => { min_value: ['{{min}}'], max_value: ['{{max}}'], max_scale: ['{{scale}}', '{{actual}}'], + // `max_precision` (#19992): both sentences carry the bound and the count; + // the `_scaled` one also names the decimal places the count was taken at. + max_precision: ['{{precision}}', '{{actual}}'], + max_precision_scaled: ['{{precision}}', '{{scale}}', '{{actual}}'], min_length: ['{{minLength}}', '{{actual}}'], max_length: ['{{maxLength}}', '{{actual}}'], invalid_option: ['{{allowed}}'], diff --git a/packages/spec/src/system/validation-message.ts b/packages/spec/src/system/validation-message.ts index e448901d7e2..9304d54f74b 100644 --- a/packages/spec/src/system/validation-message.ts +++ b/packages/spec/src/system/validation-message.ts @@ -90,6 +90,11 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> min_value: '{{label}} must be ≥ {{min}}', max_value: '{{label}} must be ≤ {{max}}', max_scale: '{{label}} must have at most {{scale}} decimal places (got {{actual}})', + // `max_precision` (#19992): two sentences, one wire code. The plain one when + // the count is the value's own digits; `_scaled` when the field's scale + // padded it, so `1234.5` at `scale: 2` reads as the 6 digits of `1234.50`. + max_precision: '{{label}} must have at most {{precision}} digits in total (got {{actual}})', + max_precision_scaled: '{{label}} must have at most {{precision}} digits in total, counting {{scale}} decimal places (got {{actual}})', invalid_email: '{{label}} must be a valid email address', invalid_url: '{{label}} must be a valid URL (scheme://...)', invalid_phone: '{{label}} must be a valid phone number', @@ -134,6 +139,8 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> min_value: '{{label}}必须大于或等于 {{min}}', max_value: '{{label}}必须小于或等于 {{max}}', max_scale: '{{label}}的小数位数不能超过 {{scale}} 位(当前 {{actual}} 位)', + max_precision: '{{label}}的总位数不能超过 {{precision}} 位(当前 {{actual}} 位)', + max_precision_scaled: '{{label}}的总位数不能超过 {{precision}} 位(按 {{scale}} 位小数计,当前 {{actual}} 位)', invalid_email: '{{label}}必须是有效的电子邮件地址', invalid_url: '{{label}}必须是有效的 URL(scheme://...)', invalid_phone: '{{label}}必须是有效的电话号码', @@ -171,6 +178,8 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> min_value: '{{label}}は {{min}} 以上で入力してください', max_value: '{{label}}は {{max}} 以下で入力してください', max_scale: '{{label}}の小数点以下は {{scale}} 桁以内で入力してください(現在 {{actual}} 桁)', + max_precision: '{{label}}は合計 {{precision}} 桁以内で入力してください(現在 {{actual}} 桁)', + max_precision_scaled: '{{label}}は小数点以下 {{scale}} 桁を含めて合計 {{precision}} 桁以内で入力してください(現在 {{actual}} 桁)', invalid_email: '{{label}}は有効なメールアドレスを入力してください', invalid_url: '{{label}}は有効な URL(scheme://...)を入力してください', invalid_phone: '{{label}}は有効な電話番号を入力してください', @@ -208,6 +217,8 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> min_value: '{{label}} debe ser mayor o igual que {{min}}', max_value: '{{label}} debe ser menor o igual que {{max}}', max_scale: '{{label}} no debe superar {{scale}} decimales (actual: {{actual}})', + max_precision: '{{label}} no debe superar {{precision}} dígitos en total (actual: {{actual}})', + max_precision_scaled: '{{label}} no debe superar {{precision}} dígitos en total, contando {{scale}} decimales (actual: {{actual}})', invalid_email: '{{label}} debe ser una dirección de correo electrónico válida', invalid_url: '{{label}} debe ser una URL válida (scheme://...)', invalid_phone: '{{label}} debe ser un número de teléfono válido',