Skip to content

Commit 9abe4e4

Browse files
os-litantclaude
andauthored
fix(spec): minLength is a positive integer, authorable only on bounded-string field types; register the deferred #11566 maxLength ADR-0087 entry (#12191)
* fix(spec): minLength is a positive integer, authorable only on bounded-string field types Issue #11949 context (maintainer ruling 2026-08-25, option B): the #11566 template applies in full. Shape tightens to z.number().int().min(1) — the lower bound is 1, so minLength: 0 is refused loudly ("no minimum" is expressed by omitting the key, not by declaring a vacuous truth). Applicability converges on BOUNDED_STRING_FIELD_TYPES via a superRefine twin of maxLength's; both authoring-form rows align to the same set (field.form.ts previously showed the key for 3 types, object.form.ts for 9). Card relationship is declared in the PR body, not here. Ruled pre-step: corpus + generator grep for minLength: 0 / negative / fractional output — zero hits repo-wide, so no cleanup rides the PR and no customer-facing generator fork exists. - field.zod.ts: shape + applicability superRefine + set doc comment - field.form.ts / object.form.ts: minLength rows aligned to the ten - field.test.ts: refusal envelopes (path + code + message) for 0 / -5 / 12.5 and for nine wrong-type representatives; byte-identical round-trip on all ten bounded-string types; minLength: 1 lower-bound pin; absence stays absent - lint RHS-rule census re-derived 44 -> 41 (3 fewer == literal comparisons after the in-list respell of the field-form row — the #11989 precedent) - D3 semantic entry field-min-length-malformed-or-misplaced-refused (major-18 one-file shard + gen:migration-registry) - changeset: @objectstack/spec minor (launch-window convention), adr-0087: registered Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV * feat(spec): register the deferred #11566 maxLength narrowing in the ADR-0087 ledger Issue #11950 context: the #11566 enforcement (PR #11989) shipped without its ADR-0087 ledger entry — the migrations registry was serialized behind an in-flight change in that wave — and this commit lands the missing half as a major-18 D3 semantic entry, following the #8321 scale/precision template. Semantics are authoritative to PR #11989's actual diff: refused shapes (0 / negative / non-integer on any type), refused placement (any value outside the ten-member BOUNDED_STRING_FIELD_TYPES set as of that landing), forms convergence, byte-identical round-trip for well-formed declarations. The entry separates the mechanical half (misplaced keys were inert by construction — the validator's bounded-string branch never read them) from the judgment half (malformed values on bounded-string types WERE consumed by the raw comparison — maxLength: 0 accepted only empty strings, a negative value refused every write — so the author must re-declare the bound they meant). No accept/reject behaviour moves in this commit. Card relationship is declared in the PR body, not here. - D3 semantic entry field-max-length-malformed-or-misplaced-refused (major-18 one-file shard + gen:migration-registry) - changeset: @objectstack/spec minor, adr-0087: registered Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV * regen: migration registry + docs references from the merged tree Discharges the os-regen deferral the merge commit recorded: registry regenerated from the (merge-updated) semantic shards; field.mdx rendered from the merged schema (minLength row at the twelve-member set). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e38d78 commit 9abe4e4

11 files changed

Lines changed: 362 additions & 12 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
The #11566 `maxLength` narrowing (shipped in 17.x: `z.number().int().min(1)`, refused outside `BOUNDED_STRING_FIELD_TYPES`) is now registered in the ADR-0087 migration ledger (#11950) — the enforcement PR deliberately deferred the entry because the registry file was serialized behind an in-flight change. Following the #8321 `scale`/`precision` template, the major-18 semantic entry carries both halves: the mechanical one (delete the key where it was misplaced — inert by construction outside the write-time validator's bounded-string branch) and the judgment one (a malformed value on a bounded-string type WAS consumed by the validator's raw comparison — `maxLength: 0` accepted only empty strings, a negative value refused every write — so only the author knows the bound they meant; the entry tells them to re-declare it). `objectstack migrate meta`, `spec-changes.json` and the upgrade guide surface the entry at the major boundary; no accept/reject behaviour changes in this release.
6+
7+
<!-- adr-0087: registered field-max-length-malformed-or-misplaced-refused -->
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+
`FieldSchema.minLength` tightens on both axes (#11949, maintainer ruling 2026-08-25) — `maxLength`'s twin defect pair (#11566), closed with the same template. Shape: the key is now `z.number().int().min(1)`, so `minLength: 0`, negative and non-integer declarations are refused at parse. The lower bound is 1 by ruling: "no minimum" is expressed by omitting the key, not by declaring a vacuous truth — `minLength: 0` can never fail, and a permanently-true declaration is exactly the noise an AI metadata author mass-produces, so it is refused loudly at authoring time. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the `BOUNDED_STRING_FIELD_TYPES` set — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, `signature`, `qrcode` (twelve members since #11875) — the same set `maxLength` converged on.
6+
7+
What newly gets rejected: `minLength: 0` / negative / non-integer on any type, and `minLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms converge on the same set (`field.form.ts` previously showed the key for three types; `object.form.ts` for nine). Already-legal declarations (a positive-integer `minLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes.
8+
9+
<!-- adr-0087: registered field-min-length-malformed-or-misplaced-refused -->

content/docs/references/data/field.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const result = CurrencyConfigSchema.parse(data);
6666
| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' |
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** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. |
69-
| **minLength** | `number` | optional | Min character length |
69+
| **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. |
7070
| **precision** | `integer` | optional | Total digits (non-negative integer) |
7171
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
7272
| **min** | `number` | optional | Minimum value |

packages/lint/src/validate-predicate-path-refs.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -598,14 +598,15 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
598598
for (const value of Object.values(rec)) rewrite(value);
599599
};
600600
rewrite(corrupted.views);
601-
// The count tracks the CORPUS, not an issue: 44 today because #11566
602-
// (PR #11989) respelled the field form's `maxLength` row from a 3-way
601+
// The count tracks the CORPUS, not an issue: 41 today because #11949
602+
// respelled the field form's `minLength` row from a 3-way
603603
// `data.type == '…'` chain to one `data.type in […]` list while aligning
604604
// it to the ten bounded-string types — 3 fewer `==` literal comparisons,
605605
// and `in`-list literals are deliberately not this rule's (see the anchor
606-
// note above). The pre-#11566 measurement was 47 and stays 47 — history,
607-
// not the census.
608-
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(44);
606+
// note above) — exactly the #11566 (PR #11989) respell of the sibling
607+
// `maxLength` row, which took the measurement from 47 to 44. Earlier
608+
// measurements stay what they were — history, not the census.
609+
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(41);
609610

610611
const rhsFindings = validatePredicatePathRefs(corrupted)
611612
.filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);

packages/spec/src/data/field.form.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ export const fieldForm = defineForm({
3535
{ field: 'defaultValue', helpText: 'Default value for new records' },
3636
{ field: 'placeholder', helpText: 'Hint text shown inside the empty input (disappears once a value is entered); use inlineHelpText for always-visible help' },
3737
// Text field options
38-
{ field: 'minLength', visibleWhen: "data.type == 'text' || data.type == 'textarea' || data.type == 'email'", helpText: 'Minimum character length' },
38+
// #11949 — `minLength` converges on the same bounded-string types as
39+
// `maxLength` below (BOUNDED_STRING_FIELD_TYPES; maintainer ruling
40+
// 2026-08-25: the #11566 template applies in full). This row used to
41+
// show the key for 3 types while the schema accepted it on every
42+
// type; it moves with the set, exactly like the row below.
43+
{ field: 'minLength', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']", helpText: 'Minimum character length' },
3944
// #11566 — `maxLength` is shown for exactly the bounded-string types
4045
// the schema accepts it on and the write-time validator enforces it
4146
// for (BOUNDED_STRING_FIELD_TYPES; maintainer ruling 2026-08-24).

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,99 @@ describe('FieldSchema', () => {
444444
}
445445
});
446446
});
447+
448+
/**
449+
* #11949 (maintainer ruling 2026-08-25) — `minLength` converges on the
450+
* #11566 template above: `maxLength`'s twin defect pair (no shape
451+
* validation + authorable on every type), the same convergence. The lower
452+
* bound is deliberately 1: `minLength: 0` is a permanently-true
453+
* declaration ("no minimum" is expressed by omitting the key), exactly
454+
* the vacuous noise an AI metadata author mass-produces, so it is
455+
* refused loudly at authoring rather than parsing cleanly and asserting
456+
* nothing.
457+
*/
458+
describe('malformed or misplaced minLength declarations are refused at authoring (#11949)', () => {
459+
const shapeCases: Array<[value: number, code: string]> = [
460+
[0, 'too_small'], // the ruled fork: a vacuous "no minimum" declaration
461+
[-5, 'too_small'],
462+
[12.5, 'invalid_type'], // non-integer count
463+
];
464+
for (const [value, code] of shapeCases) {
465+
it(`refuses minLength: ${value} on a text field with a ${code} issue at [minLength]`, () => {
466+
const result = FieldSchema.safeParse({
467+
name: 'title', label: 'Title', type: 'text', minLength: value,
468+
});
469+
expect(result.success).toBe(false);
470+
if (!result.success) {
471+
const issue = result.error.issues.find((i) => i.path[0] === 'minLength');
472+
expect(issue?.code).toBe(code);
473+
// Message substance, not just a throw: the refusal names what a
474+
// legal value looks like (int / >=1), so an AI author can fix it.
475+
expect(issue?.message).toMatch(code === 'invalid_type' ? /expected int/ : />=1/);
476+
}
477+
});
478+
}
479+
480+
// Same representative families as the maxLength block above — the
481+
// base-schema placement this key is converging away from — including
482+
// `secret` and `color`, the near-misses the #11875 ruling explicitly
483+
// left OUT of the set.
484+
const wrongTypes = [
485+
'boolean', 'number', 'date', 'select', 'lookup', 'autonumber',
486+
'formula', 'json', 'secret', 'color',
487+
] as const;
488+
for (const type of wrongTypes) {
489+
it(`refuses minLength on type: '${type}' with a custom issue at [minLength]`, () => {
490+
const result = FieldSchema.safeParse({
491+
name: 'f', label: 'F', type, minLength: 3,
492+
});
493+
expect(result.success).toBe(false);
494+
if (!result.success) {
495+
const issue = result.error.issues.find((i) => i.path[0] === 'minLength');
496+
expect(issue?.code).toBe('custom');
497+
// The refusal names the legal set and the offending type, so an
498+
// AI author can fix the declaration without leaving the message.
499+
expect(issue?.message).toMatch(/bounded string/);
500+
expect(issue?.message).toContain(`\`${type}\``);
501+
}
502+
});
503+
}
504+
505+
it('accepts a positive-integer minLength on every bounded-string type, round-tripping byte-identically', () => {
506+
// Hardcoded on purpose (not iterated off the export) so this test is
507+
// an independent measurement of the set, not a tautology. Ten at
508+
// #11566; `signature` / `qrcode` joined in #11875.
509+
const twelve = [
510+
'text', 'textarea', 'email', 'url', 'phone', 'password',
511+
'markdown', 'html', 'richtext', 'code', 'signature', 'qrcode',
512+
] as const;
513+
for (const type of twelve) {
514+
const result = FieldSchema.safeParse({
515+
name: 'f', label: 'F', type, minLength: 2,
516+
});
517+
expect(result.success).toBe(true);
518+
if (result.success) expect(result.data.minLength).toBe(2);
519+
}
520+
});
521+
522+
it('accepts minLength: 1 (the lower bound is 1, not 2) and both bounds together', () => {
523+
const result = FieldSchema.safeParse({
524+
name: 'f', label: 'F', type: 'text', minLength: 1, maxLength: 255,
525+
});
526+
expect(result.success).toBe(true);
527+
if (result.success) {
528+
expect(result.data.minLength).toBe(1);
529+
expect(result.data.maxLength).toBe(255);
530+
}
531+
});
532+
533+
it('absent minLength stays absent — no default materializes, on any type', () => {
534+
for (const type of ['text', 'boolean', 'lookup'] as const) {
535+
const result = FieldSchema.parse({ name: 'f', label: 'F', type }) as Record<string, unknown>;
536+
expect('minLength' in result).toBe(false);
537+
}
538+
});
539+
});
447540
});
448541

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

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

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,10 @@ export type FieldType = z.input<typeof FieldType>;
111111
* three lists used to disagree (field.form showed the key for 3 types,
112112
* object.form for 9, the validator enforced 10), and the validator's ten is
113113
* the only one with a measured reader. `FieldSchema` refuses `maxLength`
114-
* outside this set (see the superRefine below), and the two authoring forms
115-
* show the key for exactly this set — declared converges to enforced
116-
* (ADR-0078).
114+
* outside this set (see the superRefine below) — and, per the #11949 ruling
115+
* (2026-08-25), `minLength` too: the twin defect pair converges on the same
116+
* template — and the two authoring forms show both keys for exactly this
117+
* set — declared converges to enforced (ADR-0078).
117118
*
118119
* `signature` / `qrcode` joined in #11875 (maintainer ruling 2026-08-25,
119120
* option 1): their stored value IS the author's own string and routinely far
@@ -897,7 +898,14 @@ export const FieldSchema = lazySchema(() => {
897898
// consumer to defend itself). Which TYPES may author the key is the
898899
// superRefine below (BOUNDED_STRING_FIELD_TYPES).
899900
maxLength: z.number().int().min(1).optional().describe('Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode.'),
900-
minLength: z.number().optional().describe('Min character length'),
901+
// #11949 (maintainer ruling 2026-08-25) — `minLength` converges on the
902+
// #11566 template above, `maxLength`'s twin defect pair: same shape, same
903+
// applicability set, same forms convergence. The lower bound is deliberately
904+
// 1, not 0: "no minimum" is expressed by OMITTING the key, so `minLength: 0`
905+
// is a permanently-true declaration — exactly the vacuous noise an AI
906+
// metadata author mass-produces — and is refused loudly at authoring instead
907+
// of parsing cleanly and asserting nothing.
908+
minLength: z.number().int().min(1).optional().describe('Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode.'),
901909

902910
/** Number Constraints */
903911
// #8321 — `precision`/`scale` are digit COUNTS, so a non-integer or negative
@@ -1677,6 +1685,31 @@ export const FieldSchema = lazySchema(() => {
16771685
});
16781686
}
16791687

1688+
// [#11949] (maintainer ruling 2026-08-25 — the #11566 template applies in
1689+
// full): `minLength` is only authorable on types that store a bounded
1690+
// string — the same defect pair, the same convergence. The key sat on the
1691+
// BASE schema, so it was legal on `boolean` / `lookup` / `autonumber` —
1692+
// types where nothing bounded is stored — while the write-time validator
1693+
// has only ever applied `min_length` on the BOUNDED_STRING_FIELD_TYPES
1694+
// set. Declared converges to enforced (ADR-0078). `minLength` has no
1695+
// schema default, so `undefined` here always means "not authored" — a
1696+
// field without the key can never fire this. The message enumerates the
1697+
// set ITSELF rather than a prose copy of it — the #11875 lesson the
1698+
// `maxLength` twin above records (#12017 two-copies failure shape).
1699+
if (field.minLength !== undefined && !BOUNDED_STRING_FIELD_TYPES.has(field.type)) {
1700+
ctx.addIssue({
1701+
code: 'custom',
1702+
path: ['minLength'],
1703+
message:
1704+
`\`minLength\` is only valid on field types that store a bounded string — ` +
1705+
`${[...BOUNDED_STRING_FIELD_TYPES].map((t) => `'${t}'`).join(', ')} — ` +
1706+
`and this field is \`${field.type}\`: its stored value has no ` +
1707+
'character length for the bound to constrain, so the declaration would parse and ' +
1708+
'enforce nothing (the write-time validator applies `minLength` to exactly those ' +
1709+
'types). Drop the key, or use a bounded string type.',
1710+
});
1711+
}
1712+
16801713
// #7918 (maintainer ruling 2026-08-12, Option A): the FIELD-level
16811714
// `precision` key doubles as the currency display width — objectui's
16821715
// CurrencyField reads it, and objectui#4361 pinned authored-precision-wins

packages/spec/src/data/object.form.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,11 @@ export const objectForm = defineForm({
129129
// (the write seam now enforces their declared bound); this
130130
// visibleWhen moves with the set.
131131
{ field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']" },
132-
{ field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" },
132+
// #11949 — `minLength` aligned to the same set (maintainer ruling
133+
// 2026-08-25: the #11566 template applies in full). This row used
134+
// to stop at 9 types (`code` was the one it was missing); it moves
135+
// with the set, exactly like the row above.
136+
{ field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']" },
133137

134138
// Numeric constraints
135139
{ field: 'min', type: 'number', helpText: 'Minimum value', visibleWhen: "data.type in ['number','currency','percent','rating','slider','progress']" },

0 commit comments

Comments
 (0)