From dc6e8a6b0ec9952c22e1a82fcfb1323abc9a6160 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:12:17 +0000 Subject: [PATCH 1/6] wip(objectql,spec): enforce FieldSchema.precision at the record-validator write seam Adds the `max_precision` FieldErrorCode member, its four-locale message templates, the precision describe, and the digit-count refusal arm beside `max_scale`. Tests follow. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .../src/validation/record-validator.ts | 119 ++++++++++++++++++ packages/spec/src/api/errors.zod.ts | 5 + packages/spec/src/data/field.zod.ts | 10 +- .../spec/src/system/validation-message.ts | 11 ++ 4 files changed, 144 insertions(+), 1 deletion(-) 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/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.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', From 897593af708ba3c08e7a52496514cbfc0380df79 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:21:56 +0000 Subject: [PATCH 2/6] wip(objectql,rest,spec): precision pins, ledger row, catalog docs Validator, engine-door and REST-envelope pins for max_precision; the liveness row re-evidenced at the write seam; the message placeholder pin; the error-catalog row and the stale currency precision line in types.mdx. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- content/docs/api/error-catalog.mdx | 2 +- content/docs/protocol/objectql/types.mdx | 7 +- .../record-validator.precision.test.ts | 354 ++++++++++++++++++ packages/rest/src/import-integration.test.ts | 74 ++++ packages/spec/liveness/field.json | 11 +- .../src/system/validation-message.test.ts | 4 + 6 files changed, 443 insertions(+), 9 deletions(-) create mode 100644 packages/objectql/src/validation/record-validator.precision.test.ts 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/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..1057539466b --- /dev/null +++ b/packages/objectql/src/validation/record-validator.precision.test.ts @@ -0,0 +1,354 @@ +// 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({ 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({ + 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({ 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({ 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/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/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/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}}'], From a4254799ebc4f1a81926b26ec557401f1f4505a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:41:43 +0000 Subject: [PATCH 3/6] chore(changeset): objectql + spec minor for the precision write-seam refusal Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .../19992-field-precision-write-seam.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/19992-field-precision-write-seam.md 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. + + From 563e11d8a4994db347acf03689fa6dfe59ccf68c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:43:37 +0000 Subject: [PATCH 4/6] fix(objectql,runtime): classify the max_precision stamp site; field-address the pins check:dispatcher-error-vocabulary needs a foreign-vocabulary row for the ADR-0114 field code, as max_scale has; check:error-code-casing reads the field-level pins as field-addressed once they name their field. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- .../validation/record-validator.precision.test.ts | 7 ++++--- packages/runtime/src/dispatcher-error-vocabulary.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/validation/record-validator.precision.test.ts b/packages/objectql/src/validation/record-validator.precision.test.ts index 1057539466b..b214a427527 100644 --- a/packages/objectql/src/validation/record-validator.precision.test.ts +++ b/packages/objectql/src/validation/record-validator.precision.test.ts @@ -109,10 +109,11 @@ describe('validateRecord — the DECIMAL(p, s) reading of `precision` (#19992)', expect(fieldsOf(schema, { qty: ok }), String(ok)).toBeNull(); } const [err] = fieldsOf(schema, { qty: 12345 })!; - expect(err).toMatchObject({ code: 'max_precision', constraint: { precision: 4, scale: 0, actual: 5 } }); + 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 }, }); @@ -137,7 +138,7 @@ describe('validateRecord — the DECIMAL(p, s) reading of `precision` (#19992)', 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({ code: 'max_precision', constraint: { precision: 0, actual: 1 } }); + expect(fieldsOf(schema, { zero: 1 })?.[0]).toMatchObject({ field: 'zero', code: 'max_precision', constraint: { precision: 0, actual: 1 } }); }); }); @@ -204,7 +205,7 @@ describe('validateRecord — where `precision` binds, and where it does not (#19 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({ code: 'max_precision' }); + 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. 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', From d6056d919946b9e4bc0272c30039123088a69513 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:53:40 +0000 Subject: [PATCH 5/6] chore(spec): regenerate references for the precision describe and max_precision gen:docs output only (check:generated named check:docs as the one stale artifact). Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- content/docs/references/api/errors.mdx | 4 +++- content/docs/references/data/field.mdx | 2 +- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) 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 7a6a423eaea..4def6bd25eb 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. | From bb33240df524220882ef7064e9a9702461ebb3ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:48:00 +0000 Subject: [PATCH 6/6] chore(spec): regenerate references/data/object.mdx from the merged tree The os-regen driver kept one side of this generated file at the merge; gen:schema + gen:docs on the committed merge carry both main's action.aria tombstone row and this branch's precision describe. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude --- content/docs/references/data/object.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 4def6bd25eb..7b2dbc72690 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -478,7 +478,7 @@ const result = ApiMethod.parse(data); | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | -| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |