Skip to content

Commit d575779

Browse files
os-zhuangclaude
andauthored
feat(spec): refuse malformed Field.scale/precision declarations at authoring time (#8321) (#8756)
* wip: scale/precision refusal + D2 conversion + pins (pre artifact regen) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5tUwGM3LQoqErTfkvRW7W * wip: regenerated docs artifact (field.mdx precision/scale rows) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5tUwGM3LQoqErTfkvRW7W * merge origin/main (os-regen artifacts taken from main; regeneration follows) * chore(spec): regenerate migration registry and field reference after merging main (rail entry absorbed; 88 semantic) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e43d63a commit d575779

9 files changed

Lines changed: 351 additions & 17 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): refuse malformed field `scale`/`precision` declarations at authoring time (#8321)
6+
7+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
8+
launch-window convention ships it as `minor`; the migration prescription is
9+
registered under protocol major 18, where `os migrate meta` users will look).
10+
11+
`Field.scale` ("Decimal places") and `Field.precision` ("Total digits") are
12+
digit counts, but both parsed as bare `z.number()` — admitting `scale: 2.5`
13+
and `scale: -1`, neither of which has a defined meaning as a count. That
14+
looseness became load-bearing when #7501 made `scale` enforced at write time:
15+
the runtime branch deliberately guards on `Number.isInteger(def.scale) &&
16+
def.scale >= 0` (inventing floor/round semantics in a consumer would be PD #12
17+
guessing), so a typo'd declaration silently got **no enforcement at all** —
18+
the declared-but-inert shape that hides AI-authored metadata errors.
19+
20+
**What is refused:** a non-integer or negative `scale` or `precision`, at
21+
parse time with the issue path and substance (`invalid_type` "expected int" /
22+
`too_small` ">=0") — the house `z.number().int().min(0)` shape (ADR-0078
23+
declared=enforced).
24+
25+
**What stays accepted:** every well-formed declaration byte-identically
26+
(`0`, `2`, any non-negative integer, or no declaration).
27+
`CurrencyConfigSchema.precision` (under `currencyConfig`) is a **different
28+
surface** with its own bounds and `scale → precision` alias table — unchanged.
29+
30+
**Stored metadata is not hard-broken:** a `sys_metadata` row already at rest
31+
with a malformed value keeps loading — the ADR-0087 D2 conversion
32+
`field-malformed-scale-precision-removed` (retired from the load path,
33+
replayed by the stored-row rehydration seam and `os migrate meta`) drops the
34+
meaningless key, which is behaviour-preserving because a malformed declaration
35+
enforced nothing. The semantic entry
36+
`field-scale-precision-integer-refused` (protocol major 18) tells authors to
37+
re-declare the digit count they meant.
38+
39+
<!-- adr-0087: registered field-scale-precision-integer-refused -->

‎content/docs/references/data/field.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ const result = CurrencyConfigSchema.parse(data);
6767
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
6868
| **maxLength** | `number` | optional | Max character length |
6969
| **minLength** | `number` | optional | Min character length |
70-
| **precision** | `number` | optional | Total digits |
71-
| **scale** | `number` | optional | Decimal places |
70+
| **precision** | `integer` | optional | Total digits (non-negative integer) |
71+
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
7272
| **min** | `number` | optional | Minimum value |
7373
| **max** | `number` | optional | Maximum value |
7474
| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. |

‎packages/objectql/src/validation/record-validator.test.ts‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { FieldSchema } from '@objectstack/spec/data';
45
import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError } from './record-validator.js';
56

67
/**
@@ -797,10 +798,30 @@ describe('validateRecord — number `scale` is enforced by rejection (#7501)', (
797798
expect(() => validateRecord(schema, { free: 0.123456789 }, 'insert')).not.toThrow();
798799
});
799800

800-
it('a malformed declaration (non-integer or negative scale) stays unenforced', () => {
801-
// `scale: 2.5` has no defined meaning; inventing floor/round semantics
802-
// here would be consumer-side guessing (PD #12). It behaves exactly as
803-
// every declaration did before the branch existed: not at all.
801+
it('a malformed declaration (non-integer or negative scale) is refused at AUTHORING time (#8321)', () => {
802+
// FLIPPED by #8321 (was: "stays unenforced"). `scale: 2.5` has no defined
803+
// meaning; inventing floor/round semantics here would be consumer-side
804+
// guessing (PD #12), so the runtime branch deliberately guarded on
805+
// `Number.isInteger && >= 0` — which left a typo'd declaration silently
806+
// inert. The producer now refuses it: FieldSchema rejects non-integer and
807+
// negative scale/precision at parse, so a malformed declaration can no
808+
// longer reach this validator through authored metadata.
809+
for (const [key, value, code] of [
810+
['scale', 2.5, 'invalid_type'],
811+
['scale', -1, 'too_small'],
812+
['precision', 2.5, 'invalid_type'],
813+
['precision', -1, 'too_small'],
814+
] as const) {
815+
const result = FieldSchema.safeParse({ name: 'x', label: 'X', type: 'number', [key]: value });
816+
expect(result.success).toBe(false);
817+
if (!result.success) {
818+
expect(result.error.issues.find((i) => i.path[0] === key)?.code).toBe(code);
819+
}
820+
}
821+
// Defense-in-depth is UNCHANGED: handed a raw malformed def that bypassed
822+
// the schema (a hand-built runtime schema object, not authored metadata),
823+
// the runtime still refuses to invent semantics — the declaration enforces
824+
// nothing rather than something the author never wrote.
804825
const bad = { fields: { x: { type: 'number', scale: 2.5 }, y: { type: 'number', scale: -1 } } };
805826
expect(() => validateRecord(bad, { x: 1.234, y: 5.5 }, 'insert')).not.toThrow();
806827
});

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6498,6 +6498,103 @@ const actionGlobalNavLocationRemoved: MetadataConversion = {
64986498
},
64996499
};
65006500

6501+
/**
6502+
* Field `scale` / `precision`: malformed declarations removed (protocol 18,
6503+
* #8321).
6504+
*
6505+
* NOT a rename — both keys are digit COUNTS, so a non-integer or negative
6506+
* value (`scale: 2.5`, `precision: -1`) declares nothing the runtime could
6507+
* enforce. #7501's write-time enforcement deliberately guards on
6508+
* `Number.isInteger(v) && v >= 0` and leaves a malformed declaration
6509+
* unenforced (inventing floor/round semantics in a consumer would be PD #12
6510+
* guessing), so removing the key is a pure lossless delete: the declaration
6511+
* never had a runtime effect to lose, and after removal the field behaves
6512+
* byte-identically (unenforced). The authoring schema now refuses these
6513+
* values (`z.number().int().min(0)`, ADR-0078 declared=enforced), so this is
6514+
* **retired from the load path**: a live author gets the loud refusal and
6515+
* fixes the source; only `migrate meta` and the stored-row rehydration seam
6516+
* (`applyConversionsToStoredItem`, which replays retired entries by design)
6517+
* apply the delete — which is what keeps an existing tenant with
6518+
* `scale: 2.5` at rest in `sys_metadata` loadable rather than hard-broken.
6519+
*
6520+
* Guarded to `typeof v === 'number'`: a non-number value there was never
6521+
* schema-legal in any protocol and is a validation problem, not a conversion
6522+
* target — this seam never manufactures shape. `NaN`/`Infinity` fall in
6523+
* (`Number.isInteger` is false for both). A WELL-FORMED count (`0`, `2`, any
6524+
* non-negative integer) is untouched.
6525+
*
6526+
* ⚠️ `CurrencyConfigSchema.precision` (under `currencyConfig`) is a different
6527+
* surface with its own bounds and alias table — deliberately not walked.
6528+
*/
6529+
const fieldMalformedScalePrecisionRemoved: MetadataConversion = {
6530+
id: 'field-malformed-scale-precision-removed',
6531+
toMajor: 18,
6532+
retiredFromLoadPath: true,
6533+
surface: 'object.fields.*.scale / object.fields.*.precision',
6534+
summary:
6535+
"malformed field 'scale'/'precision' declarations (non-integer or negative) are removed — "
6536+
+ 'they were silently unenforced; the schema now refuses them at authoring (#8321)',
6537+
apply(stack, emit) {
6538+
const MALFORMED_KEYS = ['scale', 'precision'] as const;
6539+
const stripMalformed = (input: Dict, collection: string): Dict =>
6540+
mapCollection(input, collection, (owner, path) => {
6541+
const fields = owner.fields;
6542+
if (!isDict(fields)) return owner;
6543+
let changed = false;
6544+
const next: Dict = {};
6545+
for (const [name, def] of Object.entries(fields)) {
6546+
if (!isDict(def)) {
6547+
next[name] = def;
6548+
continue;
6549+
}
6550+
let current = def;
6551+
for (const key of MALFORMED_KEYS) {
6552+
const value = current[key];
6553+
if (typeof value !== 'number') continue;
6554+
if (Number.isInteger(value) && value >= 0) continue;
6555+
const { [key]: _removed, ...rest } = current;
6556+
current = rest;
6557+
emit({ from: `${key}: ${value}`, to: '(removed)', path: `${path}.fields.${name}.${key}` });
6558+
}
6559+
next[name] = current;
6560+
if (current !== def) changed = true;
6561+
}
6562+
return changed ? { ...owner, fields: next } : owner;
6563+
});
6564+
return stripMalformed(stripMalformed(stack, 'objects'), 'objectExtensions');
6565+
},
6566+
fixture: {
6567+
before: {
6568+
objects: [{
6569+
name: 'crm_deal',
6570+
label: 'Deal',
6571+
fields: {
6572+
// Non-integer scale: the #8321 repro — declared, silently unenforced.
6573+
amount: { type: 'number', precision: 18, scale: 2.5 },
6574+
// Negative precision: the other malformed direction.
6575+
weight: { type: 'number', precision: -1, scale: 2 },
6576+
// Well-formed counts survive byte-identically.
6577+
quantity: { type: 'number', precision: 10, scale: 0 },
6578+
},
6579+
}],
6580+
},
6581+
after: {
6582+
objects: [{
6583+
name: 'crm_deal',
6584+
label: 'Deal',
6585+
fields: {
6586+
amount: { type: 'number', precision: 18 },
6587+
weight: { type: 'number', scale: 2 },
6588+
quantity: { type: 'number', precision: 10, scale: 0 },
6589+
},
6590+
}],
6591+
},
6592+
// One per removed malformed key — `quantity` (and `weight.scale`,
6593+
// `amount.precision`) are untouched.
6594+
expectedNotices: 2,
6595+
},
6596+
};
6597+
65016598
export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConversion[]>> = {
65026599
11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename],
65036600
13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition],
@@ -6570,6 +6667,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
65706667
appHiddenToUnpublished,
65716668
actionGlobalNavLocationRemoved,
65726669
],
6670+
18: [fieldMalformedScalePrecisionRemoved],
65736671
};
65746672

65756673
/** Flattened, deterministic list of every conversion the loader knows about. */

‎packages/spec/src/conversions/stored.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,33 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows, #3903)', () =>
4040
expect('execute' in out.actions[0]!).toBe(false);
4141
});
4242

43+
// #8321 — the authoring schema now refuses non-integer/negative
44+
// `scale`/`precision`, but a tenant with `scale: 2.5` already at rest in
45+
// `sys_metadata` must NOT become unloadable: the retired D2 conversion
46+
// drops the meaningless key (behaviour-preserving — #7501's enforcement
47+
// deliberately skipped malformed declarations, so it enforced nothing)
48+
// and the rest of the row survives byte-identically.
49+
it('drops a malformed stored scale/precision instead of breaking the row (#8321)', () => {
50+
const row = {
51+
name: 'crm_deal',
52+
label: 'Deal',
53+
fields: {
54+
amount: { type: 'number', precision: 18, scale: 2.5 },
55+
weight: { type: 'number', precision: -1, scale: 2 },
56+
quantity: { type: 'number', precision: 10, scale: 0 },
57+
},
58+
};
59+
const notices: ConversionNotice[] = [];
60+
const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) });
61+
expect(out.fields.amount).toEqual({ type: 'number', precision: 18 });
62+
expect(out.fields.weight).toEqual({ type: 'number', scale: 2 });
63+
// A well-formed count is untouched.
64+
expect(out.fields.quantity).toEqual({ type: 'number', precision: 10, scale: 0 });
65+
expect(
66+
notices.filter((n) => n.conversionId === 'field-malformed-scale-precision-removed'),
67+
).toHaveLength(2);
68+
});
69+
4370
it('accepts the legacy PLURAL row-type spelling', () => {
4471
const row = { name: 'crm_deal', label: 'Deal', sharingModel: 'read' };
4572
const out = applyConversionsToStoredItem('objects', row) as Record<string, unknown>;

‎packages/spec/src/data/field.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,71 @@ describe('FieldSchema', () => {
238238

239239
expect(() => FieldSchema.parse(numberField)).not.toThrow();
240240
});
241+
242+
/**
243+
* #8321 — `scale`/`precision` are digit COUNTS, so a non-integer or
244+
* negative declaration has no defined meaning. #7501's write-time
245+
* enforcement deliberately leaves a malformed declaration UNENFORCED
246+
* (inventing floor/round semantics would be PD #12 guessing), which made
247+
* `scale: 2.5` silently inert. The producer now refuses it at parse
248+
* (ADR-0078 declared=enforced; `z.number().int().min(0)`).
249+
*/
250+
describe('malformed scale/precision declarations are refused at authoring (#8321)', () => {
251+
const cases: Array<[key: 'scale' | 'precision', value: number, code: string]> = [
252+
['scale', 2.5, 'invalid_type'], // the issue repro: non-integer count
253+
['scale', -1, 'too_small'],
254+
['precision', 2.5, 'invalid_type'],
255+
['precision', -1, 'too_small'],
256+
];
257+
for (const [key, value, code] of cases) {
258+
it(`refuses ${key}: ${value} with a ${code} issue at [${key}]`, () => {
259+
const result = FieldSchema.safeParse({
260+
name: 'work_hours', label: 'Work Hours', type: 'number', [key]: value,
261+
});
262+
expect(result.success).toBe(false);
263+
if (!result.success) {
264+
const issue = result.error.issues.find((i) => i.path[0] === key);
265+
expect(issue?.code).toBe(code);
266+
// Message substance, not just a throw: the refusal names what a
267+
// legal value looks like (int / >=0), so an AI author can fix it.
268+
expect(issue?.message).toMatch(code === 'invalid_type' ? /expected int/ : />=0/);
269+
}
270+
});
271+
}
272+
273+
it('every legal declaration still parses byte-identically (0, positive integers, absent)', () => {
274+
for (const legal of [
275+
{ scale: 0 },
276+
{ scale: 2 },
277+
{ precision: 18, scale: 2 },
278+
{ precision: 0 },
279+
{}, // neither declared — no new default appears
280+
]) {
281+
const input = { name: 'amount', label: 'Amount', type: 'number' as const, ...legal };
282+
const result = FieldSchema.safeParse(input);
283+
expect(result.success).toBe(true);
284+
if (result.success) {
285+
// Byte-identical on this surface: the declared counts round-trip
286+
// unchanged and absence stays absence.
287+
expect(result.data.scale).toBe((legal as { scale?: number }).scale);
288+
expect(result.data.precision).toBe((legal as { precision?: number }).precision);
289+
}
290+
}
291+
});
292+
293+
it('does NOT touch CurrencyConfigSchema.precision — a different surface with its own bounds', () => {
294+
// The currency config keeps its own `.int().min(0).max(10)` contract
295+
// and its `scale → precision` alias table (#7501-thread trap): `10` is
296+
// legal there, and `scale` under currencyConfig renames to precision
297+
// rather than being judged by Field.scale's contract.
298+
const result = FieldSchema.safeParse({
299+
name: 'price', label: 'Price', type: 'currency',
300+
currencyConfig: { precision: 10 },
301+
});
302+
expect(result.success).toBe(true);
303+
if (result.success) expect(result.data.currencyConfig?.precision).toBe(10);
304+
});
305+
});
241306
});
242307

243308
describe('useGrouping — number-field digit-grouping presentation hint (#7768)', () => {

‎packages/spec/src/data/field.zod.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -639,8 +639,18 @@ export const FieldSchema = lazySchema(() => strictObject({
639639
minLength: z.number().optional().describe('Min character length'),
640640

641641
/** Number Constraints */
642-
precision: z.number().optional().describe('Total digits'),
643-
scale: z.number().optional().describe('Decimal places'),
642+
// #8321 — `precision`/`scale` are digit COUNTS, so a non-integer or negative
643+
// declaration has no defined meaning. #7501 made `scale` enforced at write
644+
// time with a deliberate `Number.isInteger(def.scale) && def.scale >= 0`
645+
// runtime guard that leaves a malformed declaration UNENFORCED (inventing
646+
// floor/round semantics in a consumer would be PD #12 guessing) — so
647+
// `scale: 2.5` silently got no enforcement at all: the declared-but-inert
648+
// shape that hides AI-authored metadata errors. Refuse it at the producer
649+
// instead (ADR-0078 declared=enforced; house pattern `z.number().int().min(0)`).
650+
// ⚠️ `CurrencyConfigSchema.precision` above is a DIFFERENT surface with its
651+
// own alias table (`scale → precision` there) — do not conflate.
652+
precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer)'),
653+
scale: z.number().int().min(0).optional().describe('Decimal places (non-negative integer)'),
644654
min: z.number().optional().describe('Minimum value'),
645655
max: z.number().optional().describe('Maximum value'),
646656
/**
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'field-scale-precision-integer-refused',
7+
surface: 'object field `scale` / `precision` declarations (`Field.number` and friends) — '
8+
+ 'non-integer or negative values (`scale: 2.5`, `precision: -1`)',
9+
replacement: 'a non-negative integer digit count, or no declaration at all. The mechanical '
10+
+ 'conversion (`field-malformed-scale-precision-removed`) deletes a malformed value — '
11+
+ 'behaviour-preserving, because #7501\'s enforcement deliberately skipped malformed '
12+
+ 'declarations, so they enforced nothing — but only the author knows the count they '
13+
+ 'MEANT (`scale: 2.5` was probably `2` or `3`): re-declare it deliberately if the '
14+
+ 'constraint was wanted',
15+
reason:
16+
'Both keys are digit COUNTS ("Total digits" / "Decimal places"), and `z.number()` admitted '
17+
+ 'values with no defined meaning as a count. That looseness became load-bearing when #7501 '
18+
+ 'made `scale` enforced at write time: the runtime branch deliberately guards on '
19+
+ '`Number.isInteger(def.scale) && def.scale >= 0` — inventing floor/round semantics in a '
20+
+ 'consumer would be PD #12 guessing — so a typo\'d declaration (`scale: 2.5`) silently got '
21+
+ 'no enforcement at all: exactly the declared-but-inert shape that hides AI-authored '
22+
+ 'metadata errors. The schema now refuses non-integer and negative values for both keys at '
23+
+ 'parse time (`z.number().int().min(0)`, ADR-0078 declared=enforced). '
24+
+ '`CurrencyConfigSchema.precision` (under `currencyConfig`) is a different surface with its '
25+
+ 'own bounds and alias table and is unchanged.',
26+
acceptanceCriteria:
27+
'Every field declaring `scale` or `precision` carries a non-negative integer. Well-formed '
28+
+ 'declarations (`0`, `2`, any non-negative integer) parse byte-identically to before; '
29+
+ 'fields declaring neither key are untouched. Stored `sys_metadata` rows carrying a '
30+
+ 'malformed value keep loading (the rehydration seam replays the conversion, which drops '
31+
+ 'the meaningless key).',
32+
};

0 commit comments

Comments
 (0)