From 31f73cb86fda6045c48a88ee780e2bd24bf2663e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:10:51 +0000 Subject: [PATCH 1/3] feat(objectql): enforce number field `scale` by rejection (#7501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declared `scale` was documentation, not a constraint: the number branch of record-validator tested only `def.min`/`def.max`, so `scale: 0` accepted 11.5 and stored it verbatim through both the REST create route and the CSV import route. Per the maintainer ruling of 2026-08-11 (issue comment 5250623270), an over-scale value is now REFUSED — 400 VALIDATION_FAILED with field code `max_scale` and constraint `{ scale, actual }` — symmetric with min_value/max_value. Never rounded: silent rounding is silently altering data. New writes only; stored legacy values are not migrated. - spec: `max_scale` joins the closed FieldErrorCode catalog (ADR-0114 D2) and the built-in validation message catalog in all four locales - objectql: scale branch after min/max; decimal places measured from the number's canonical string form (exponent-safe, no overflow) - tests: unit pins (repro declaration, negative pins for integers / min/max / unconstrained fields, exponent forms, malformed declarations), CSV import leg, dry-run parity, direct create-route 400 envelope Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --- content/docs/references/api/errors.mdx | 6 +- .../src/validation/record-validator.test.ts | 108 ++++++++++++++++++ .../src/validation/record-validator.ts | 49 ++++++++ packages/rest/src/import-integration.test.ts | 92 ++++++++++++++- packages/spec/src/api/errors.zod.ts | 4 + .../src/system/validation-message.test.ts | 1 + .../spec/src/system/validation-message.ts | 4 + 7 files changed, 261 insertions(+), 3 deletions(-) diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index 4f557fdf35..8f36098ef9 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -48,7 +48,7 @@ const result = EnhancedApiErrorSchema.parse(data); | **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | | **retryAfter** | `number` | optional | Seconds to wait before retrying | | **details** | `any` | optional | Additional error context | -| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| … +23 more>; message: string; label?: string; … }[]` | optional | One entry per offending value | +| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| … +24 more>; message: string; label?: string; … }[]` | optional | One entry per offending value | | **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4, #3977) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | | **timestamp** | `string` | optional | When the error occurred | | **requestId** | `string` | optional | Request ID for tracking | @@ -152,7 +152,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' \| … +18 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' \| … +19 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 | @@ -177,6 +177,7 @@ const result = EnhancedApiErrorSchema.parse(data); * `max_length` * `min_value` * `max_value` +* `max_scale` * `min_items` * `max_items` * `invalid_option` @@ -212,6 +213,7 @@ const result = EnhancedApiErrorSchema.parse(data); * `max_length` * `min_value` * `max_value` +* `max_scale` * `min_items` * `max_items` * `invalid_option` diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index 4712572bca..9e25b207a9 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -704,3 +704,111 @@ describe('validateRecord — messages name the field by its label (#3957)', () = } }); }); + +/** + * #7501 — a number field's declared `scale` is ENFORCED, by rejection. + * + * `scale` sat in the field contract next to `precision`/`min`/`max` (all + * constraints) but had no validator branch at all: `{ scale: 0 }` accepted + * `11.5` and stored it verbatim. Maintainer ruling 2026-08-11: enforce by + * refusing (`max_scale`), NEVER by rounding — silent rounding is silently + * altering data. Applies to new writes only; stored legacy values rest. + * + * The fixture is the issue's own repro declaration (`work_hours`). + */ +describe('validateRecord — number `scale` is enforced by rejection (#7501)', () => { + const schema = { + fields: { + work_hours: { + type: 'number', label: 'Max hours per shift', + precision: 5, scale: 0, min: 1, max: 12, + }, + rate: { type: 'number', label: 'Rate', scale: 2 }, + free: { type: 'number', label: 'Free' }, // no scale — unconstrained + }, + }; + + const fieldsOf = (data: Record, mode: 'insert' | 'update' = 'insert', options = {}) => { + try { + validateRecord(schema, data, mode, options); + } catch (e) { + return (e as ValidationError).fields; + } + throw new Error('expected a ValidationError'); + }; + + it('rejects the issue repro: 11.5 into scale: 0 — with the envelope, not just a throw', () => { + const [err] = fieldsOf({ work_hours: 11.5 }); + expect(err).toMatchObject({ + field: 'work_hours', + code: 'max_scale', + constraint: { scale: 0, actual: 1 }, + }); + expect(err.message).toBe('Max hours per shift must have at most 0 decimal places (got 1)'); + // The thrown error is the VALIDATION_FAILED envelope REST maps to 400. + try { + validateRecord(schema, { work_hours: 11.5 }, 'insert'); + throw new Error('expected a ValidationError'); + } catch (e) { + expect((e as ValidationError).code).toBe('VALIDATION_FAILED'); + } + }); + + it('scale: 0 does NOT start refusing integers (the repro declaration keeps working)', () => { + expect(() => validateRecord(schema, { work_hours: 12 }, 'insert')).not.toThrow(); + expect(() => validateRecord(schema, { work_hours: 1 }, 'update')).not.toThrow(); + }); + + it('a value within a non-zero scale still writes; one past it is refused', () => { + expect(() => validateRecord(schema, { rate: 3.25 }, 'insert')).not.toThrow(); + expect(() => validateRecord(schema, { rate: 3.2 }, 'insert')).not.toThrow(); + expect(() => validateRecord(schema, { rate: 3 }, 'insert')).not.toThrow(); + const [err] = fieldsOf({ rate: 3.256 }); + expect(err).toMatchObject({ code: 'max_scale', constraint: { scale: 2, actual: 3 } }); + }); + + it('rejects on update too — the same branch runs for both modes', () => { + const [err] = fieldsOf({ work_hours: 2.5 }, 'update'); + expect(err).toMatchObject({ field: 'work_hours', code: 'max_scale' }); + }); + + it('string-carried numbers (a CSV cell) are judged after coercion, same as min/max', () => { + const [err] = fieldsOf({ work_hours: '11.5' }); + expect(err).toMatchObject({ code: 'max_scale', constraint: { scale: 0, actual: 1 } }); + expect(() => validateRecord(schema, { work_hours: '11' }, 'insert')).not.toThrow(); + }); + + it('exponent forms are normalized, not read as zero decimals', () => { + const [err] = fieldsOf({ rate: 1e-7 }); // 0.0000001 — 7 places + expect(err).toMatchObject({ code: 'max_scale', constraint: { scale: 2, actual: 7 } }); + // A positive exponent means an INTEGER — must not be refused. + expect(() => validateRecord(schema, { work_hours: 1.2e1 }, 'insert')).not.toThrow(); + }); + + it('min/max on the same field are unchanged — and outrank scale in report order', () => { + // -1 violates min AND scale is fine (integer): still min_value, as before. + const [minErr] = fieldsOf({ work_hours: -1 }); + expect(minErr).toMatchObject({ code: 'min_value', constraint: { min: 1 } }); + const [maxErr] = fieldsOf({ work_hours: 13 }); + expect(maxErr).toMatchObject({ code: 'max_value', constraint: { max: 12 } }); + }); + + it('a field with no declared scale accepts any precision (no new default)', () => { + expect(() => validateRecord(schema, { free: 0.123456789 }, 'insert')).not.toThrow(); + }); + + it('a malformed declaration (non-integer or negative scale) stays unenforced', () => { + // `scale: 2.5` has no defined meaning; inventing floor/round semantics + // here would be consumer-side guessing (PD #12). It behaves exactly as + // every declaration did before the branch existed: not at all. + const bad = { fields: { x: { type: 'number', scale: 2.5 }, y: { type: 'number', scale: -1 } } }; + expect(() => validateRecord(bad, { x: 1.234, y: 5.5 }, 'insert')).not.toThrow(); + }); + + it('renders the refusal fully localized (no half-translated sentence)', () => { + const [err] = fieldsOf({ work_hours: 11.5 }, 'insert', { + messages: { locale: 'zh-CN', objectName: 'shift' }, + }); + expect(err.message).toBe('Max hours per shift的小数位数不能超过 0 位(当前 1 位)'); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 006a64a775..e77356c593 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -21,6 +21,8 @@ * an omitted field never 400s — legacy null rows rest. * - `maxLength` / `minLength` (text/textarea/email/url/phone/password) * - `min` / `max` (number/currency/percent/rating/slider) + * - `scale` more decimal places than declared → `max_scale` (#7501; + * rejection, NEVER rounding — maintainer ruling 2026-08-11) * - format email / url / phone (lightweight RFC-aware regex) * - select / multiselect: value must appear in `options` * - boolean / toggle: must coerce to boolean @@ -161,6 +163,8 @@ interface FieldDef { minLength?: number; min?: number; max?: number; + /** Max decimal places for number types — enforced by rejection (#7501). */ + scale?: number; options?: Array<{ value: string | number; label?: string } | string | number>; } @@ -168,6 +172,33 @@ function isMissing(v: unknown): boolean { return v === undefined || v === null || (typeof v === 'string' && v.trim() === ''); } +/** + * How many decimal places a finite number carries (#7501). + * + * Measured from the number's own canonical string form — `String(n)` — rather + * than by multiply-and-round arithmetic, for two reasons: + * + * - `n * 10 ** scale` overflows to `Infinity` for large magnitudes + * (`1e308` at `scale: 10`), turning an integral value into a false + * rejection; the string form never overflows. + * - the canonical string is exactly what JSON serialization produces, so the + * count judged here is the count the client's own payload showed. A float + * artifact like `0.1 + 0.2` really IS `0.30000000000000004` on the wire, + * and judging the canonical form reports it as the 17 decimal places the + * stored value would carry — the honest verdict under a rejection contract. + * + * Exponent forms are normalized: `1e-7` → 7 places, `1.5e-7` → 8, + * `1.23e+21` → 0. Callers guard `Number.isFinite` first; a non-numeric string + * (which `String` would render as `NaN`) falls out of the regex and counts 0. + */ +function decimalPlacesOf(n: number): number { + const m = /^-?\d+(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(String(n)); + if (!m) return 0; + const fractionDigits = m[1] ? m[1].length : 0; + const exponent = m[2] ? Number(m[2]) : 0; + return Math.max(0, fractionDigits - exponent); +} + /** * What the validator needs in order to speak the caller's language (#3957). * @@ -532,6 +563,24 @@ function validateOne( if (def.max !== undefined && n > def.max) { return fail('max_value', { max: def.max }); } + // ── `scale` — enforced by REJECTION, never rounding (#7501) ── + // Maintainer ruling 2026-08-11: an over-scale value is refused the way an + // out-of-range one is; silent rounding is silently altering data. Applies + // to NEW writes only — already-stored values are read back untouched. + // Only a well-formed declaration (integer ≥ 0) is enforced: `scale: 2.5` + // has no defined meaning, and inventing one here (floor? round?) would be + // the consumer-side guessing PD #12 forbids — a malformed declaration + // stays unenforced exactly as every declaration was before this branch. + if ( + def.scale !== undefined && + Number.isInteger(def.scale) && + def.scale >= 0 + ) { + const actual = decimalPlacesOf(n); + if (actual > def.scale) { + return fail('max_scale', { scale: def.scale, actual }); + } + } return null; } diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts index 6fa4fd8ad9..927511e911 100644 --- a/packages/rest/src/import-integration.test.ts +++ b/packages/rest/src/import-integration.test.ts @@ -103,6 +103,14 @@ const MEMBER = { // framework#3956 — bounded fields. The dry run used to ignore both. penalty_amount: { name: 'penalty_amount', type: 'number' as const, label: '处罚金额', min: 0, max: 9999999.99 }, nickname: { name: 'nickname', type: 'text' as const, label: 'Nickname', maxLength: 5 }, + // framework#7501 — the issue's own repro declaration: `scale: 0` declares + // "integer, no decimals", and the import path is the leg that proves the + // ruling (reject, never round) — a rounding validator would have quietly + // stored 12 here and every assertion below would still need to fail it. + work_hours: { + name: 'work_hours', type: 'number' as const, label: 'Max hours per shift', + precision: 5, scale: 0, min: 1, max: 12, + }, }, }; @@ -142,7 +150,7 @@ async function boot() { const route = rest.getRoutes().find( (r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import', ); - return { engine, protocol, route }; + return { engine, protocol, route, rest }; } const call = (route: any, body: any) => { @@ -585,3 +593,85 @@ describe('import route — named mapping artifact (#2611)', () => { expect(res._json.code).toBe('MAPPING_FORMAT_MISMATCH'); }); }); + +// --------------------------------------------------------------------------- +// framework#7501 — declared `scale` is enforced by REJECTION on both write +// legs the issue measured: the direct data create route and the CSV import +// route. Ruling 2026-08-11: refuse (`max_scale`), never round — the import +// leg is the one that proves it, because a rounding "fix" would store 12 for +// 11.5 and report the row created; only a refusal leaves the row unwritten. +// New writes only: nothing here migrates or re-judges stored rows. +// --------------------------------------------------------------------------- +describe('import + create routes — number `scale` enforcement (#7501)', () => { + let route: any; + let engine: any; + let rest: any; + beforeEach(async () => { ({ route, engine, rest } = await boot()); }); + + const imp = (body: any) => { + const res = makeRes(); + return route.handler({ params: { object: 'member' }, body } as any, res).then(() => res); + }; + + it('CSV import refuses an over-scale cell and does NOT store a rounded value', async () => { + const csv = [ + 'ID,Name,Status,Hours', + 'w1,Ivy,active,11.5', // scale: 0 — must be refused, not rounded to 12 + 'w2,Joe,active,12', // integer, in range — must still write + ].join('\n'); + const res = await imp({ + format: 'csv', csv, + mapping: { ID: 'id', Name: 'member_name', Status: 'status', Hours: 'work_hours' }, + }); + 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({ + ok: false, action: 'failed', field: 'work_hours', code: 'max_scale', + error: 'Max hours per shift must have at most 0 decimal places (got 1)', + }); + // The refused row left NOTHING behind — neither 11.5 nor a rounded 12. + expect(await engine.findOne('member', { where: { id: 'w1' } })).toBeNull(); + expect((await engine.findOne('member', { where: { id: 'w2' } }))?.work_hours).toBe(12); + }); + + it('dry run predicts the same refusal — same verdict, same message', async () => { + // 2.5 is inside [min: 1, max: 12] — the ONLY violated constraint is scale, + // so this cannot pass by riding the pre-existing min_value branch. + const rows = [{ id: 'w3', member_name: 'Kim', status: 'active', work_hours: 2.5 }]; + const dry = await imp({ format: 'json', dryRun: true, rows }); + expect(dry._json).toMatchObject({ dryRun: true, total: 1, ok: 0, errors: 1, created: 0 }); + expect(dry._json.results[0]).toMatchObject({ + row: 1, ok: false, action: 'failed', field: 'work_hours', code: 'max_scale', + error: 'Max hours per shift must have at most 0 decimal places (got 1)', + }); + expect(await engine.findOne('member', { where: { id: 'w3' } })).toBeNull(); + }); + + it('the direct create route answers 400 VALIDATION_FAILED + max_scale (code AND status)', 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: 'w4', member_name: 'Lea', status: 'active', work_hours: 11.5 }, + } as any, res); + expect(res._status).toBe(400); + expect(res._json).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(res._json.fields[0]).toMatchObject({ + field: 'work_hours', code: 'max_scale', + constraint: { scale: 0, actual: 1 }, + }); + expect(await engine.findOne('member', { where: { id: 'w4' } })).toBeNull(); + + // …and a within-scale value on the same declaration still writes (201-class). + const ok = makeRes(); + await create.handler({ + params: { object: 'member' }, + body: { id: 'w5', member_name: 'Mo', status: 'active', work_hours: 8 }, + } as any, ok); + expect(ok._status ?? 200).toBeLessThan(400); + expect((await engine.findOne('member', { where: { id: 'w5' } }))?.work_hours).toBe(8); + }); +}); diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts index 0c661bf621..7da0dffcf3 100644 --- a/packages/spec/src/api/errors.zod.ts +++ b/packages/spec/src/api/errors.zod.ts @@ -251,6 +251,10 @@ export const FieldErrorCode = z.enum([ 'max_length', 'min_value', 'max_value', + // more decimal places than the field's declared `scale` allows (#7501) — + // `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', 'min_items', 'max_items', // closed sets and references diff --git a/packages/spec/src/system/validation-message.test.ts b/packages/spec/src/system/validation-message.test.ts index f8aae19e8f..f140ee4d5b 100644 --- a/packages/spec/src/system/validation-message.test.ts +++ b/packages/spec/src/system/validation-message.test.ts @@ -55,6 +55,7 @@ describe('validation message catalog — completeness', () => { const required: Record = { min_value: ['{{min}}'], max_value: ['{{max}}'], + max_scale: ['{{scale}}', '{{actual}}'], min_length: ['{{minLength}}', '{{actual}}'], max_length: ['{{maxLength}}', '{{actual}}'], invalid_option: ['{{allowed}}'], diff --git a/packages/spec/src/system/validation-message.ts b/packages/spec/src/system/validation-message.ts index 12bd5b5c28..091cbc68b8 100644 --- a/packages/spec/src/system/validation-message.ts +++ b/packages/spec/src/system/validation-message.ts @@ -89,6 +89,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> max_length: '{{label}} must be ≤ {{maxLength}} characters (got {{actual}})', min_value: '{{label}} must be ≥ {{min}}', max_value: '{{label}} must be ≤ {{max}}', + max_scale: '{{label}} must have at most {{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', @@ -124,6 +125,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> // catches it — the inconsistency #3957 reported. min_value: '{{label}}必须大于或等于 {{min}}', max_value: '{{label}}必须小于或等于 {{max}}', + max_scale: '{{label}}的小数位数不能超过 {{scale}} 位(当前 {{actual}} 位)', invalid_email: '{{label}}必须是有效的电子邮件地址', invalid_url: '{{label}}必须是有效的 URL(scheme://...)', invalid_phone: '{{label}}必须是有效的电话号码', @@ -156,6 +158,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> max_length: '{{label}}は {{maxLength}} 文字以内で入力してください(現在 {{actual}} 文字)', min_value: '{{label}}は {{min}} 以上で入力してください', max_value: '{{label}}は {{max}} 以下で入力してください', + max_scale: '{{label}}の小数点以下は {{scale}} 桁以内で入力してください(現在 {{actual}} 桁)', invalid_email: '{{label}}は有効なメールアドレスを入力してください', invalid_url: '{{label}}は有効な URL(scheme://...)を入力してください', invalid_phone: '{{label}}は有効な電話番号を入力してください', @@ -188,6 +191,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> max_length: '{{label}} no debe superar {{maxLength}} caracteres (actual: {{actual}})', 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}})', 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 eaa70586645ad3435abcc1661755dac94f336225 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:23:58 +0000 Subject: [PATCH 2/3] chore: changeset for #7501 scale enforcement Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --- .../number-scale-enforced-by-rejection.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .changeset/number-scale-enforced-by-rejection.md diff --git a/.changeset/number-scale-enforced-by-rejection.md b/.changeset/number-scale-enforced-by-rejection.md new file mode 100644 index 0000000000..e3a0d3ed51 --- /dev/null +++ b/.changeset/number-scale-enforced-by-rejection.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +--- + +feat(objectql,spec): a number field's declared `scale` is enforced — by rejection, never rounding (#7501) + +`scale` sat in the field contract next to `precision`, `min` and `max` — all of +which read as constraints — but had no validator branch at all: a field declared +`scale: 0` ("integer, no decimals") accepted `11.5` and stored it verbatim, with +no error, no rounding, no warning, through both the REST create endpoint and the +CSV import endpoint. A downstream app that declared `scale: 0` to express "whole +number" shipped on the reasonable assumption that the declaration was enforced +the way `min`/`max` are; it was documentation. + +Per the maintainer ruling of 2026-08-11, the number branch of the record +validator now **refuses** an over-scale value the way it refuses an +out-of-range one — `400 VALIDATION_FAILED` with field code `max_scale` and +`constraint: { scale, actual }` — and deliberately does **not** round: silent +rounding is silently altering data. `max_scale` joins the closed field-level +error catalog (ADR-0114 D2) in the `max_*` bounded-range family, with built-in +messages in all four platform locales. + +Scope and edges: + +- **New writes only.** Values already stored under the old non-enforcement are + not migrated, re-judged, or touched on read. +- **Every write leg is covered by the one branch**: REST create/update, CSV/JSON + import (real write *and* dry run — the dry run asks the engine for its + verdict, so import previews report the same refusal), and flow/hook writes + that pass through `validateRecord`. +- Decimal places are measured from the number's canonical string form, so + exponent notation is judged correctly (`1e-7` is 7 decimal places, `1.2e+3` + is 0) and large magnitudes cannot overflow into false rejections. +- `min`/`max` behave exactly as before and are checked first; an integer into + `scale: 0` still writes; a field with no declared `scale` accepts any + precision. A malformed declaration (negative or non-integer `scale`) stays + unenforced rather than being given invented semantics. + +If a deployment was knowingly storing over-scale values into a field that +declares `scale`, the declaration and the data now disagree loudly: widen or +remove the field's `scale` to match what you actually store. From 66a7a3ccb17c9950a148168a0bf9b7d6d49066a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:34:26 +0000 Subject: [PATCH 3/3] docs: add max_scale to the hand-written field-level error catalog (#7501) The group table in content/docs/api/error-catalog.mdx enumerates the closed FieldErrorCode catalog exhaustively (27 members pre-change, one per group row); max_scale joins the bounded-ranges row after max_value, matching the enum's own ordering. The three example lines above the table are illustrative (3 of 27 codes, mirroring the errors.zod.ts doc examples) and stay as-is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --- content/docs/api/error-catalog.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index 76ebd90bb5..5e706fc50b 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -528,7 +528,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`, `min_items`, `max_items` | +| Bounded ranges | `min_length`, `max_length`, `min_value`, `max_value`, `max_scale`, `min_items`, `max_items` | | Closed sets and references | `invalid_option`, `invalid_value`, `reference_not_found`, `reference_ambiguous` | | Declarative rules | `rule_violation`, `json_schema_violation`, `invalid_initial_state`, `invalid_transition` |