Summary
A number field that declares scale is not validated against it at runtime — a value with more decimal places than scale allows is accepted and stored verbatim. There is no error, no rounding, no warning.
Measured on @objectstack/*@17.0.0-rc.6, driver-sql (better-sqlite3), via both the REST create endpoint and the CSV import endpoint.
Repro
Field declaration:
work_hours: {
type: 'number',
label: 'Max hours per shift',
precision: 5,
scale: 0, // ← declares "integer, no decimals"
defaultValue: 12,
min: 1,
max: 12,
}
POST /api/v1/data/<object> with { "work_hours": 11.5 }
- Expected: rejected (
400, an INVALID_* envelope), or at minimum rounded to the declared scale.
- Actual:
HTTP 201, and the stored value is 11.5 — verified by reading the row straight out of storage, not through the API.
Same result with scale: 1 and a two-decimal input, and same result through
POST /api/v1/data/<object>/import (format: "csv").
min / max on the same field are enforced (-1 → 400, code: "min_value"), so the validator does run — it just has no branch for scale.
Where it comes from
record-validator's number branch only tests def.min and def.max. A full search of
objectql / runtime / cli for scale finds it only in @objectstack/spec as descriptive
metadata ("Decimal places") — there is no runtime consumer anywhere in the shipped packages.
So scale today is documentation, not a constraint. That is surprising given it sits next to
precision, min and max in the same field declaration, all of which read as constraints.
Why it matters
A downstream app declared scale: 0 to express "this must be a whole number" and shipped it,
reasonably assuming the declaration was enforced the way min/max are. It is not — decimals
flow through to storage. There is no way to express "integer" in the field contract at all today,
so every such rule has to be re-implemented in an application hook, one field at a time.
Ask
Either of these would close it:
- Enforce
scale in the number validator (reject, or round to the declared scale — either is
predictable, silence is not), consistent with how min/max already behave; or
- If
scale is intentionally display-only, say so in the spec description (it currently reads
just "Decimal places", which does not distinguish "stored with" from "rendered with"), and
provide a real way to declare an integer constraint.
Secondary observation (same field, same request path)
The min/max rejection message is half-translated: the field label is localized but the
sentence template is not, producing e.g.
Max hours per shift must be ≥ 1
in a zh-CN environment. It reaches end users verbatim on the import path, so an app cannot present
a fully localized message without intercepting and rewriting it. Mentioning it here rather than
opening a second card since it is the same validator; happy to split it out if preferred.
Summary
A
numberfield that declaresscaleis not validated against it at runtime — a value with more decimal places thanscaleallows is accepted and stored verbatim. There is no error, no rounding, no warning.Measured on
@objectstack/*@17.0.0-rc.6,driver-sql(better-sqlite3), via both the REST create endpoint and the CSV import endpoint.Repro
Field declaration:
POST /api/v1/data/<object>with{ "work_hours": 11.5 }400, anINVALID_*envelope), or at minimum rounded to the declared scale.HTTP 201, and the stored value is11.5— verified by reading the row straight out of storage, not through the API.Same result with
scale: 1and a two-decimal input, and same result throughPOST /api/v1/data/<object>/import(format: "csv").min/maxon the same field are enforced (-1→400,code: "min_value"), so the validator does run — it just has no branch forscale.Where it comes from
record-validator'snumberbranch only testsdef.minanddef.max. A full search ofobjectql/runtime/cliforscalefinds it only in@objectstack/specas descriptivemetadata (
"Decimal places") — there is no runtime consumer anywhere in the shipped packages.So
scaletoday is documentation, not a constraint. That is surprising given it sits next toprecision,minandmaxin the same field declaration, all of which read as constraints.Why it matters
A downstream app declared
scale: 0to express "this must be a whole number" and shipped it,reasonably assuming the declaration was enforced the way
min/maxare. It is not — decimalsflow through to storage. There is no way to express "integer" in the field contract at all today,
so every such rule has to be re-implemented in an application hook, one field at a time.
Ask
Either of these would close it:
scalein the number validator (reject, or round to the declared scale — either ispredictable, silence is not), consistent with how
min/maxalready behave; orscaleis intentionally display-only, say so in the spec description (it currently readsjust
"Decimal places", which does not distinguish "stored with" from "rendered with"), andprovide a real way to declare an integer constraint.
Secondary observation (same field, same request path)
The
min/maxrejection message is half-translated: the field label is localized but thesentence template is not, producing e.g.
in a zh-CN environment. It reaches end users verbatim on the import path, so an app cannot present
a fully localized message without intercepting and rewriting it. Mentioning it here rather than
opening a second card since it is the same validator; happy to split it out if preferred.