Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/number-scale-enforced-by-rejection.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/api/error-catalog.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |

Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/api/errors.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand DownExpand Up@@ -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 |
Expand All@@ -177,6 +177,7 @@ const result = EnhancedApiErrorSchema.parse(data);
* `max_length`
* `min_value`
* `max_value`
* `max_scale`
* `min_items`
* `max_items`
* `invalid_option`
Expand DownExpand Up@@ -212,6 +213,7 @@ const result = EnhancedApiErrorSchema.parse(data);
* `max_length`
* `min_value`
* `max_value`
* `max_scale`
* `min_items`
* `max_items`
* `invalid_option`
Expand Down
108 changes: 108 additions & 0 deletions packages/objectql/src/validation/record-validator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>, 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 位)');
});
});
49 changes: 49 additions & 0 deletions packages/objectql/src/validation/record-validator.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -161,13 +163,42 @@ 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>;
}

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).
*
Expand DownExpand Up@@ -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;
}

Expand Down
92 changes: 91 additions & 1 deletion packages/rest/src/import-integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
},
},
};

Expand DownExpand Up@@ -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) => {
Expand DownExpand Up@@ -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);
});
});
Loading
Loading