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
7 changes: 7 additions & 0 deletions .changeset/maxlength-adr0087-ledger-entry.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/spec': minor
---

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.

<!-- adr-0087: registered field-max-length-malformed-or-misplaced-refused -->
9 changes: 9 additions & 0 deletions .changeset/minlength-bounded-string-only.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/spec': minor
---

`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.

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.

<!-- adr-0087: registered field-min-length-malformed-or-misplaced-refused -->
2 changes: 1 addition & 1 deletion content/docs/references/data/field.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,7 @@ const result = CurrencyConfigSchema.parse(data);
| **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' |
| **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. |
| **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. |
| **minLength** | `number` | optional | Min character length |
| **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. |
| **precision** | `integer` | optional | Total digits (non-negative integer) |
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
| **min** | `number` | optional | Minimum value |
Expand Down
11 changes: 6 additions & 5 deletions packages/lint/src/validate-predicate-path-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -598,14 +598,15 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
for (const value of Object.values(rec)) rewrite(value);
};
rewrite(corrupted.views);
// The count tracks the CORPUS, not an issue: 44 today because #11566
// (PR #11989) respelled the field form's `maxLength` row from a 3-way
// The count tracks the CORPUS, not an issue: 41 today because #11949
// respelled the field form's `minLength` row from a 3-way
// `data.type == '…'` chain to one `data.type in […]` list while aligning
// it to the ten bounded-string types — 3 fewer `==` literal comparisons,
// and `in`-list literals are deliberately not this rule's (see the anchor
// note above). The pre-#11566 measurement was 47 and stays 47 — history,
// not the census.
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(44);
// note above) — exactly the #11566 (PR #11989) respell of the sibling
// `maxLength` row, which took the measurement from 47 to 44. Earlier
// measurements stay what they were — history, not the census.
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(41);

const rhsFindings = validatePredicatePathRefs(corrupted)
.filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/data/field.form.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,12 @@ export const fieldForm = defineForm({
{ field: 'defaultValue', helpText: 'Default value for new records' },
{ field: 'placeholder', helpText: 'Hint text shown inside the empty input (disappears once a value is entered); use inlineHelpText for always-visible help' },
// Text field options
{ field: 'minLength', visibleWhen: "data.type == 'text' || data.type == 'textarea' || data.type == 'email'", helpText: 'Minimum character length' },
// #11949 — `minLength` converges on the same bounded-string types as
// `maxLength` below (BOUNDED_STRING_FIELD_TYPES; maintainer ruling
// 2026-08-25: the #11566 template applies in full). This row used to
// show the key for 3 types while the schema accepted it on every
// type; it moves with the set, exactly like the row below.
{ field: 'minLength', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']", helpText: 'Minimum character length' },
// #11566 — `maxLength` is shown for exactly the bounded-string types
// the schema accepts it on and the write-time validator enforces it
// for (BOUNDED_STRING_FIELD_TYPES; maintainer ruling 2026-08-24).
Expand Down
93 changes: 93 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,6 +444,99 @@ describe('FieldSchema', () => {
}
});
});

/**
* #11949 (maintainer ruling 2026-08-25) — `minLength` converges on the
* #11566 template above: `maxLength`'s twin defect pair (no shape
* validation + authorable on every type), the same convergence. The lower
* bound is deliberately 1: `minLength: 0` is a permanently-true
* declaration ("no minimum" is expressed by omitting the key), exactly
* the vacuous noise an AI metadata author mass-produces, so it is
* refused loudly at authoring rather than parsing cleanly and asserting
* nothing.
*/
describe('malformed or misplaced minLength declarations are refused at authoring (#11949)', () => {
const shapeCases: Array<[value: number, code: string]> = [
[0, 'too_small'], // the ruled fork: a vacuous "no minimum" declaration
[-5, 'too_small'],
[12.5, 'invalid_type'], // non-integer count
];
for (const [value, code] of shapeCases) {
it(`refuses minLength: ${value} on a text field with a ${code} issue at [minLength]`, () => {
const result = FieldSchema.safeParse({
name: 'title', label: 'Title', type: 'text', minLength: value,
});
expect(result.success).toBe(false);
if (!result.success) {
const issue = result.error.issues.find((i) => i.path[0] === 'minLength');
expect(issue?.code).toBe(code);
// Message substance, not just a throw: the refusal names what a
// legal value looks like (int / >=1), so an AI author can fix it.
expect(issue?.message).toMatch(code === 'invalid_type' ? /expected int/ : />=1/);
}
});
}

// Same representative families as the maxLength block above — the
// base-schema placement this key is converging away from — including
// `secret` and `color`, the near-misses the #11875 ruling explicitly
// left OUT of the set.
const wrongTypes = [
'boolean', 'number', 'date', 'select', 'lookup', 'autonumber',
'formula', 'json', 'secret', 'color',
] as const;
for (const type of wrongTypes) {
it(`refuses minLength on type: '${type}' with a custom issue at [minLength]`, () => {
const result = FieldSchema.safeParse({
name: 'f', label: 'F', type, minLength: 3,
});
expect(result.success).toBe(false);
if (!result.success) {
const issue = result.error.issues.find((i) => i.path[0] === 'minLength');
expect(issue?.code).toBe('custom');
// The refusal names the legal set and the offending type, so an
// AI author can fix the declaration without leaving the message.
expect(issue?.message).toMatch(/bounded string/);
expect(issue?.message).toContain(`\`${type}\``);
}
});
}

it('accepts a positive-integer minLength on every bounded-string type, round-tripping byte-identically', () => {
// Hardcoded on purpose (not iterated off the export) so this test is
// an independent measurement of the set, not a tautology. Ten at
// #11566; `signature` / `qrcode` joined in #11875.
const twelve = [
'text', 'textarea', 'email', 'url', 'phone', 'password',
'markdown', 'html', 'richtext', 'code', 'signature', 'qrcode',
] as const;
for (const type of twelve) {
const result = FieldSchema.safeParse({
name: 'f', label: 'F', type, minLength: 2,
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.minLength).toBe(2);
}
});

it('accepts minLength: 1 (the lower bound is 1, not 2) and both bounds together', () => {
const result = FieldSchema.safeParse({
name: 'f', label: 'F', type: 'text', minLength: 1, maxLength: 255,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.minLength).toBe(1);
expect(result.data.maxLength).toBe(255);
}
});

it('absent minLength stays absent — no default materializes, on any type', () => {
for (const type of ['text', 'boolean', 'lookup'] as const) {
const result = FieldSchema.parse({ name: 'f', label: 'F', type }) as Record<string, unknown>;
expect('minLength' in result).toBe(false);
}
});
});
});

describe('useGrouping — number-field digit-grouping presentation hint (#7768)', () => {
Expand Down
41 changes: 37 additions & 4 deletions packages/spec/src/data/field.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,9 +111,10 @@ export type FieldType = z.input<typeof FieldType>;
* three lists used to disagree (field.form showed the key for 3 types,
* object.form for 9, the validator enforced 10), and the validator's ten is
* the only one with a measured reader. `FieldSchema` refuses `maxLength`
* outside this set (see the superRefine below), and the two authoring forms
* show the key for exactly this set — declared converges to enforced
* (ADR-0078).
* outside this set (see the superRefine below) — and, per the #11949 ruling
* (2026-08-25), `minLength` too: the twin defect pair converges on the same
* template — and the two authoring forms show both keys for exactly this
* set — declared converges to enforced (ADR-0078).
*
* `signature` / `qrcode` joined in #11875 (maintainer ruling 2026-08-25,
* option 1): their stored value IS the author's own string and routinely far
Expand DownExpand Up@@ -897,7 +898,14 @@ export const FieldSchema = lazySchema(() => {
// consumer to defend itself). Which TYPES may author the key is the
// superRefine below (BOUNDED_STRING_FIELD_TYPES).
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.'),
minLength: z.number().optional().describe('Min character length'),
// #11949 (maintainer ruling 2026-08-25) — `minLength` converges on the
// #11566 template above, `maxLength`'s twin defect pair: same shape, same
// applicability set, same forms convergence. The lower bound is deliberately
// 1, not 0: "no minimum" is expressed by OMITTING the key, so `minLength: 0`
// is a permanently-true declaration — exactly the vacuous noise an AI
// metadata author mass-produces — and is refused loudly at authoring instead
// of parsing cleanly and asserting nothing.
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.'),

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

// [#11949] (maintainer ruling 2026-08-25 — the #11566 template applies in
// full): `minLength` is only authorable on types that store a bounded
// string — the same defect pair, the same convergence. The key sat on the
// BASE schema, so it was legal on `boolean` / `lookup` / `autonumber` —
// types where nothing bounded is stored — while the write-time validator
// has only ever applied `min_length` on the BOUNDED_STRING_FIELD_TYPES
// set. Declared converges to enforced (ADR-0078). `minLength` has no
// schema default, so `undefined` here always means "not authored" — a
// field without the key can never fire this. The message enumerates the
// set ITSELF rather than a prose copy of it — the #11875 lesson the
// `maxLength` twin above records (#12017 two-copies failure shape).
if (field.minLength !== undefined && !BOUNDED_STRING_FIELD_TYPES.has(field.type)) {
ctx.addIssue({
code: 'custom',
path: ['minLength'],
message:
`\`minLength\` is only valid on field types that store a bounded string — ` +
`${[...BOUNDED_STRING_FIELD_TYPES].map((t) => `'${t}'`).join(', ')} — ` +
`and this field is \`${field.type}\`: its stored value has no ` +
'character length for the bound to constrain, so the declaration would parse and ' +
'enforce nothing (the write-time validator applies `minLength` to exactly those ' +
'types). Drop the key, or use a bounded string type.',
});
}

// #7918 (maintainer ruling 2026-08-12, Option A): the FIELD-level
// `precision` key doubles as the currency display width — objectui's
// CurrencyField reads it, and objectui#4361 pinned authored-precision-wins
Expand Down
6 changes: 5 additions & 1 deletion packages/spec/src/data/object.form.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,7 +129,11 @@ export const objectForm = defineForm({
// (the write seam now enforces their declared bound); this
// visibleWhen moves with the set.
{ field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']" },
{ field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" },
// #11949 — `minLength` aligned to the same set (maintainer ruling
// 2026-08-25: the #11566 template applies in full). This row used
// to stop at 9 types (`code` was the one it was missing); it moves
// with the set, exactly like the row above.
{ field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']" },

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