Skip to content

Commit e914733

Browse files
huangyiireneclaude
andauthored
fix(spec): FormField constraint keys converge on the landed value shapes (int lengths >= 1, digit counts >= 0) (#12458)
* fix(spec): FormField constraint keys converge on the landed value shapes (#12174) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy * chore(spec): regenerate migration registry projection and docs (#12174) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4297fe7 commit e914733

8 files changed

Lines changed: 274 additions & 15 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`FormFieldSchema`'s constraint keys converge on the value shapes the object-field surface landed (#12174): `maxLength`/`minLength` tighten from bare `z.number()` to `z.number().int().min(1)` (the #11566/#11949 template — a character length is a positive integer, and `minLength: 0` is a vacuous always-true declaration refused loudly; "no minimum" is expressed by omitting the key), and `precision`/`scale` tighten to `z.number().int().min(0)` (the #8321 template — digit counts). `min`/`max` are unchanged: they are values, not counts, exactly as on the object-field surface.
6+
7+
The row keys are live, measured in objectui: the spec bridge (`form-view.ts` mapField, objectui#5898) and plugin-form (`sectionFields.ts`) copy all six onto the runtime field, the console FormPage merges `override.maxLength ?? def.maxLength` onto the rendered input (objectui#5595), the fields package builds validation rules from `minLength`/`maxLength`/`min`/`max`, and `precision`/`scale` drive the number widgets — so `maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and `GET /forms/:slug` serves the rows verbatim to anonymous renderers. What newly gets rejected: `0` (length pair only), negative and non-integer values on the four count-shaped keys. Unlike the object-field twins there is no type-conditional applicability gate: a form row references its object field by name and usually omits `type`, so the referenced field's type is invisible at parse time — value shape is checkable here, key placement stays the object field's own schema's job. Already-legal declarations round-trip byte-identically, and absence stays absence — no default materializes.
8+
9+
<!-- adr-0087: registered ui-form-field-length-malformed-refused, ui-form-field-precision-scale-integer-refused -->

content/docs/references/ui/view.mdx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,12 @@ Column footer summary configuration
140140
| **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields |
141141
| **reference** | `string` | optional | Target object name for lookup/master_detail fields |
142142
| **publicPicker** | `{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }` | optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. |
143-
| **maxLength** | `number` | optional | Maximum character length (for text/textarea/email/url/phone) |
144-
| **minLength** | `number` | optional | Minimum character length |
143+
| **maxLength** | `integer` | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) |
144+
| **minLength** | `integer` | optional | Minimum character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key) |
145145
| **min** | `number` | optional | Minimum value (for number/currency/percent/slider) |
146146
| **max** | `number` | optional | Maximum value |
147-
| **precision** | `number` | optional | Total digits (for number/currency) |
148-
| **scale** | `number` | optional | Decimal places |
147+
| **precision** | `integer` | optional | Total digits (non-negative integer; for number/currency) |
148+
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
149149
| **multiple** | `boolean` | optional | Allow multiple values (for select/lookup/file/image) |
150150
| **label** | `string \| Record<string, string>` | optional | Display label override |
151151
| **placeholder** | `string \| Record<string, string>` | optional | Placeholder text |
@@ -302,12 +302,12 @@ View filter rule
302302
| **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields |
303303
| **reference** | `string` | optional | Target object name for lookup/master_detail fields |
304304
| **publicPicker** | `{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }` | optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. |
305-
| **maxLength** | `number` | optional | Maximum character length (for text/textarea/email/url/phone) |
306-
| **minLength** | `number` | optional | Minimum character length |
305+
| **maxLength** | `integer` | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) |
306+
| **minLength** | `integer` | optional | Minimum character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key) |
307307
| **min** | `number` | optional | Minimum value (for number/currency/percent/slider) |
308308
| **max** | `number` | optional | Maximum value |
309-
| **precision** | `number` | optional | Total digits (for number/currency) |
310-
| **scale** | `number` | optional | Decimal places |
309+
| **precision** | `integer` | optional | Total digits (non-negative integer; for number/currency) |
310+
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
311311
| **multiple** | `boolean` | optional | Allow multiple values (for select/lookup/file/image) |
312312
| **label** | `string \| Record<string, string>` | optional | Display label override |
313313
| **placeholder** | `string \| Record<string, string>` | optional | Placeholder text |

packages/spec/liveness/view.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@
317317
"verifiedAt": "2026-08-11",
318318
"evidenceScope": "cross-repo",
319319
"evidence": "objectui: form renderers + spec-bridge form-view.ts:169 (audit L17); packages/rest/src/rest-server.ts registerPublicFormEndpoints (the three /forms/:slug routes walk sections: resolve strips undeclared lookup/master_detail/user fields, submit builds allowedFields from them, and GET /forms/:slug/lookup/:field reads each field entry's publicPicker block)",
320-
"note": "This row carries the blanket verdict for the whole FormField subtree beneath it (undrilled inheritance). [#7467] adds `publicPicker` {displayFields,maxResults,filter,object} to that subtree — live, measured reader: the GET /forms/:slug/lookup/:field handler in packages/rest/src/rest-server.ts (displayFields projection + contains search, maxResults clamp to hard ceiling 50, filter rows composed ahead of the search predicate, object override for the referenced object). The key had been ENFORCED there but declared nowhere — ADR-0049's mirror direction (enforced, never declarable) — until the maintainer ruled declare (option 1). The route also reads picker.sort, which stays deliberately UNDECLARED (outside the ruling's four-key enumeration; follow-up finding filed from #7467); a form authoring it is a loud parse error, pinned in view-public-picker.test.ts."
320+
"note": "This row carries the blanket verdict for the whole FormField subtree beneath it (undrilled inheritance). [#7467] adds `publicPicker` {displayFields,maxResults,filter,object} to that subtree — live, measured reader: the GET /forms/:slug/lookup/:field handler in packages/rest/src/rest-server.ts (displayFields projection + contains search, maxResults clamp to hard ceiling 50, filter rows composed ahead of the search predicate, object override for the referenced object). The key had been ENFORCED there but declared nowhere — ADR-0049's mirror direction (enforced, never declarable) — until the maintainer ruled declare (option 1). The route also reads picker.sort, which stays deliberately UNDECLARED (outside the ruling's four-key enumeration; follow-up finding filed from #7467); a form authoring it is a loud parse error, pinned in view-public-picker.test.ts. [#12174] drills the six FormField constraint keys out of the blanket verdict — maxLength/minLength/min/max/precision/scale are all LIVE, measured at objectui@f7c52e2 (2026-08-26): two independent spec-to-runtime routes copy all six onto the runtime field (packages/react/src/spec-bridge/bridges/form-view.ts mapField, objectui#5898; packages/plugin-form/src/sectionFields.ts normalizeSectionField), the console FormPage merges override.maxLength ?? def.maxLength onto the rendered input (apps/console/src/components/FormPage.tsx:548, objectui#5595), packages/fields buildValidationRules turns minLength/maxLength/min/max into react-hook-form rules, ObjectForm.tsx:691 derives the number input step from precision, and NumberField.tsx:21 reads scale; server-side, GET /forms/:slug (packages/rest/src/rest-server.ts) serves the rows verbatim to anonymous renderers (a conduit, no key-level read). On that measurement the four count-shaped keys tightened to the #11566/#11949/#8321 value shapes (lengths int>=1, precision/scale int>=0; min/max stay bare numbers — they are values, not counts)."
321321
},
322322
"groups": {
323323
"status": "live",
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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: 'ui-form-field-length-malformed-refused',
7+
surface: 'form-view field row `maxLength` / `minLength` declarations '
8+
+ '(`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — '
9+
+ '`0`, negative or non-integer values',
10+
replacement: 'a positive-integer bound (>= 1), or no declaration at all ("no minimum" is '
11+
+ 'expressed by OMITTING `minLength`, never by `minLength: 0`). The row-level key is a '
12+
+ 'per-form override that can only NARROW what the referenced object field already '
13+
+ 'declares (the object field surface tightened first: #11566/#11949) — so a malformed '
14+
+ 'row value is deleted, and a bound that was actually wanted is re-declared as a '
15+
+ 'positive integer, or dropped in favour of the object field\'s own authoritative '
16+
+ 'declaration',
17+
reason:
18+
'#12174: the form-field row carried the pre-#11566 shape — bare `z.number()` — after '
19+
+ 'the object-field surface converged (`maxLength` #11566, `minLength` #11949, both '
20+
+ '`z.number().int().min(1)`). The row keys are LIVE, measured in objectui: the spec '
21+
+ 'bridge (`packages/react/src/spec-bridge/bridges/form-view.ts` mapField, '
22+
+ 'objectui#5898) and plugin-form (`sectionFields.ts` normalizeSectionField) both copy '
23+
+ 'them onto the runtime field, the console FormPage merges `override.maxLength ?? '
24+
+ 'def.maxLength` onto the rendered input (objectui#5595), and the fields package '
25+
+ 'builds react-hook-form validation rules from `minLength`/`maxLength` — so '
26+
+ '`maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and '
27+
+ 'the public-form resolve route (`GET /forms/:slug`) serves the rows verbatim to '
28+
+ 'anonymous renderers. The schema now refuses the malformed values at parse '
29+
+ '(`z.number().int().min(1)`, ADR-0078 declared=enforced). Unlike the object-field '
30+
+ 'twins there is NO type-conditional gate: a form row references its object field by '
31+
+ 'name and usually omits `type`, so the referenced field\'s type is invisible at parse '
32+
+ 'time — value shape is checkable on this surface, key placement is the object '
33+
+ 'field\'s own schema\'s job.',
34+
acceptanceCriteria:
35+
'Every form-view field row declaring `maxLength` or `minLength` carries a positive '
36+
+ 'integer. Well-formed rows (a positive-integer bound) parse byte-identically to '
37+
+ 'before; rows declaring neither key are untouched, and absence stays absence — no '
38+
+ 'default materializes. A stored form view carrying a malformed row value is refused '
39+
+ 'on its next authoring-path save with a prescriptive per-key issue; the author '
40+
+ 'deletes the key (the object field\'s own declaration keeps governing the write '
41+
+ 'seam) or re-declares the intended positive integer.',
42+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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: 'ui-form-field-precision-scale-integer-refused',
7+
surface: 'form-view field row `precision` / `scale` declarations '
8+
+ '(`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — '
9+
+ 'non-integer or negative values (`scale: 2.5`, `precision: -1`)',
10+
replacement: 'a non-negative integer digit count, or no declaration at all. The row-level '
11+
+ 'key is a per-form override of the referenced object field\'s own declaration (that '
12+
+ 'surface tightened first: #8321) — a malformed row value is deleted, and a count '
13+
+ 'that was actually wanted is re-declared as a non-negative integer (`scale: 2.5` was '
14+
+ 'probably `2` or `3`)',
15+
reason:
16+
'#12174: the form-field row carried the pre-#8321 shape — bare `z.number()` — after '
17+
+ 'the object-field surface converged on `z.number().int().min(0)` for both digit '
18+
+ 'counts. The row keys are LIVE, measured in objectui: the spec bridge '
19+
+ '(`form-view.ts` mapField, objectui#5898) and plugin-form (`sectionFields.ts`) copy '
20+
+ 'them onto the runtime field, `ObjectForm` derives the number input\'s step from '
21+
+ '`precision`, and the `NumberField` widget reads `scale` — so a malformed count '
22+
+ 'flowed into rendering arithmetic (`Math.pow(10, -precision)`) with no defined '
23+
+ 'meaning. The schema now refuses non-integer and negative values for both keys at '
24+
+ 'parse time (ADR-0078 declared=enforced). Same no-type-gate rationale as the length '
25+
+ 'pair entry (`ui-form-field-length-malformed-refused`): the row usually omits '
26+
+ '`type`, so only value shape is checkable on this surface. '
27+
+ '⚠️ `CurrencyConfigSchema.precision` and the gantt `scale` enum are different '
28+
+ 'surfaces and are unchanged.',
29+
acceptanceCriteria:
30+
'Every form-view field row declaring `precision` or `scale` carries a non-negative '
31+
+ 'integer. Well-formed rows (`0`, `2`, any non-negative integer) parse '
32+
+ 'byte-identically to before; rows declaring neither key are untouched, and absence '
33+
+ 'stays absence. A stored form view carrying a malformed row value is refused on its '
34+
+ 'next authoring-path save with a prescriptive per-key issue; the author deletes the '
35+
+ 'key or re-declares the integer they meant.',
36+
};

packages/spec/src/migrations/registry.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6687,6 +6687,76 @@ const step18: MigrationStep = {
66876687
+ 'and behaviour that seems to need one is a renderer capability request against '
66886688
+ 'objectui, not a metadata key.',
66896689
},
6690+
{
6691+
id: 'ui-form-field-length-malformed-refused',
6692+
surface: 'form-view field row `maxLength` / `minLength` declarations '
6693+
+ '(`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — '
6694+
+ '`0`, negative or non-integer values',
6695+
replacement: 'a positive-integer bound (>= 1), or no declaration at all ("no minimum" is '
6696+
+ 'expressed by OMITTING `minLength`, never by `minLength: 0`). The row-level key is a '
6697+
+ 'per-form override that can only NARROW what the referenced object field already '
6698+
+ 'declares (the object field surface tightened first: #11566/#11949) — so a malformed '
6699+
+ 'row value is deleted, and a bound that was actually wanted is re-declared as a '
6700+
+ 'positive integer, or dropped in favour of the object field\'s own authoritative '
6701+
+ 'declaration',
6702+
reason:
6703+
'#12174: the form-field row carried the pre-#11566 shape — bare `z.number()` — after '
6704+
+ 'the object-field surface converged (`maxLength` #11566, `minLength` #11949, both '
6705+
+ '`z.number().int().min(1)`). The row keys are LIVE, measured in objectui: the spec '
6706+
+ 'bridge (`packages/react/src/spec-bridge/bridges/form-view.ts` mapField, '
6707+
+ 'objectui#5898) and plugin-form (`sectionFields.ts` normalizeSectionField) both copy '
6708+
+ 'them onto the runtime field, the console FormPage merges `override.maxLength ?? '
6709+
+ 'def.maxLength` onto the rendered input (objectui#5595), and the fields package '
6710+
+ 'builds react-hook-form validation rules from `minLength`/`maxLength` — so '
6711+
+ '`maxLength: 0` on a form row reached the DOM as an input that accepts nothing, and '
6712+
+ 'the public-form resolve route (`GET /forms/:slug`) serves the rows verbatim to '
6713+
+ 'anonymous renderers. The schema now refuses the malformed values at parse '
6714+
+ '(`z.number().int().min(1)`, ADR-0078 declared=enforced). Unlike the object-field '
6715+
+ 'twins there is NO type-conditional gate: a form row references its object field by '
6716+
+ 'name and usually omits `type`, so the referenced field\'s type is invisible at parse '
6717+
+ 'time — value shape is checkable on this surface, key placement is the object '
6718+
+ 'field\'s own schema\'s job.',
6719+
acceptanceCriteria:
6720+
'Every form-view field row declaring `maxLength` or `minLength` carries a positive '
6721+
+ 'integer. Well-formed rows (a positive-integer bound) parse byte-identically to '
6722+
+ 'before; rows declaring neither key are untouched, and absence stays absence — no '
6723+
+ 'default materializes. A stored form view carrying a malformed row value is refused '
6724+
+ 'on its next authoring-path save with a prescriptive per-key issue; the author '
6725+
+ 'deletes the key (the object field\'s own declaration keeps governing the write '
6726+
+ 'seam) or re-declares the intended positive integer.',
6727+
},
6728+
{
6729+
id: 'ui-form-field-precision-scale-integer-refused',
6730+
surface: 'form-view field row `precision` / `scale` declarations '
6731+
+ '(`FormFieldSchema`, the rows inside `FormView.sections[].fields[]`) — '
6732+
+ 'non-integer or negative values (`scale: 2.5`, `precision: -1`)',
6733+
replacement: 'a non-negative integer digit count, or no declaration at all. The row-level '
6734+
+ 'key is a per-form override of the referenced object field\'s own declaration (that '
6735+
+ 'surface tightened first: #8321) — a malformed row value is deleted, and a count '
6736+
+ 'that was actually wanted is re-declared as a non-negative integer (`scale: 2.5` was '
6737+
+ 'probably `2` or `3`)',
6738+
reason:
6739+
'#12174: the form-field row carried the pre-#8321 shape — bare `z.number()` — after '
6740+
+ 'the object-field surface converged on `z.number().int().min(0)` for both digit '
6741+
+ 'counts. The row keys are LIVE, measured in objectui: the spec bridge '
6742+
+ '(`form-view.ts` mapField, objectui#5898) and plugin-form (`sectionFields.ts`) copy '
6743+
+ 'them onto the runtime field, `ObjectForm` derives the number input\'s step from '
6744+
+ '`precision`, and the `NumberField` widget reads `scale` — so a malformed count '
6745+
+ 'flowed into rendering arithmetic (`Math.pow(10, -precision)`) with no defined '
6746+
+ 'meaning. The schema now refuses non-integer and negative values for both keys at '
6747+
+ 'parse time (ADR-0078 declared=enforced). Same no-type-gate rationale as the length '
6748+
+ 'pair entry (`ui-form-field-length-malformed-refused`): the row usually omits '
6749+
+ '`type`, so only value shape is checkable on this surface. '
6750+
+ '⚠️ `CurrencyConfigSchema.precision` and the gantt `scale` enum are different '
6751+
+ 'surfaces and are unchanged.',
6752+
acceptanceCriteria:
6753+
'Every form-view field row declaring `precision` or `scale` carries a non-negative '
6754+
+ 'integer. Well-formed rows (`0`, `2`, any non-negative integer) parse '
6755+
+ 'byte-identically to before; rows declaring neither key are untouched, and absence '
6756+
+ 'stays absence. A stored form view carrying a malformed row value is refused on its '
6757+
+ 'next authoring-path save with a prescriptive per-key issue; the author deletes the '
6758+
+ 'key or re-declares the integer they meant.',
6759+
},
66906760
{
66916761
id: 'ui-record-blocks-unknown-keys-refused',
66926762
surface: 'page `record:alert` / `record:quick_actions` / `record:history` / '

0 commit comments

Comments
 (0)