diff --git a/.changeset/maxlength-adr0087-ledger-entry.md b/.changeset/maxlength-adr0087-ledger-entry.md new file mode 100644 index 0000000000..22479142fd --- /dev/null +++ b/.changeset/maxlength-adr0087-ledger-entry.md @@ -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. + + diff --git a/.changeset/minlength-bounded-string-only.md b/.changeset/minlength-bounded-string-only.md new file mode 100644 index 0000000000..6856591ead --- /dev/null +++ b/.changeset/minlength-bounded-string-only.md @@ -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. + + diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index b70d67b397..6484ec166c 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -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 | diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index c337cd16b5..9d23a452d3 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -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); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 36629eb983..09a936f51a 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -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). diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 057b553606..453c23d55e 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -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; + expect('minLength' in result).toBe(false); + } + }); + }); }); describe('useGrouping — number-field digit-grouping presentation hint (#7768)', () => { diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index b77c10416e..b0a0b180f1 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -111,9 +111,10 @@ export type FieldType = z.input; * 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 @@ -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 @@ -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 diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 32b93b2aa9..8befcd8bc5 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -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']" }, diff --git a/packages/spec/src/migrations/entries/semantic/18.field-max-length-malformed-or-misplaced-refused.ts b/packages/spec/src/migrations/entries/semantic/18.field-max-length-malformed-or-misplaced-refused.ts new file mode 100644 index 0000000000..39d454ba59 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.field-max-length-malformed-or-misplaced-refused.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'field-max-length-malformed-or-misplaced-refused', + surface: 'object field `maxLength` declarations — `maxLength: 0`, negative or non-integer ' + + 'values on any type, and the key with any value on field types outside ' + + '`BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, ' + + '`json`, `secret`, …)', + replacement: 'a positive-integer `maxLength` (>= 1) on a bounded-string field type — `text`, ' + + '`textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, ' + + 'plus `signature`/`qrcode` since #11875 (the set is `BOUNDED_STRING_FIELD_TYPES`; the ' + + '#11566 narrowing itself landed on the ten-member set of its day) ' + + '— or no declaration at all. Deleting the key is mechanical and behaviour-preserving ' + + 'for a MISPLACED declaration: the write-time validator only ever applied `max_length` ' + + 'inside its bounded-string branch, so the key was inert by construction on every other ' + + 'type. A MALFORMED value on a bounded-string type is the judgment case — the ' + + 'validator\'s raw `>` comparison did consume it (`maxLength: 0` accepted only the ' + + 'empty string, a negative value refused every write, `maxLength: 12.5` behaved as ' + + '"at most 12"), and the SQL schema-drift planner consumed `maxLength: 0` as ' + + '`varchar(0)` DDL until #11431 taught it to defend itself — so only the author knows ' + + 'the bound they MEANT: re-declare it as a positive integer, or delete it deliberately ' + + 'accepting the unbounding', + reason: + '#11566 (maintainer ruling 2026-08-24; enforcement shipped on the 17.x line in PR ' + + '#11989 — accept-set narrowings ride minors, and this entry tells `migrate meta` users ' + + 'at the major boundary; registration was deferred to #11950 because the registry file ' + + 'was serialized behind an in-flight change when the enforcement landed). Shape: a ' + + 'character length is a positive integer, so the key tightened from `z.number()` to ' + + '`z.number().int().min(1)` — `maxLength: 0` measurably sent schema-drift planning ' + + '`varchar(0)` DDL no server accepts, at severity error/destructive, before #11431 ' + + 'taught that consumer to defend itself (the #8321 `precision`/`scale` house pattern). ' + + 'Applicability: the key sat on the BASE field schema — authorable on `boolean` / ' + + '`lookup` / `autonumber`, types where nothing bounded is stored — while the write-time ' + + 'validator (objectql `record-validator.ts`) only ever enforced it on its ten ' + + 'bounded-string types, the one list of the three that had a measured reader; that list ' + + 'is promoted to the protocol as `BOUNDED_STRING_FIELD_TYPES`, the schema refuses the ' + + 'key outside it (ADR-0078 declared=enforced), and both authoring forms ' + + '(`field.form.ts`, previously 3 types; `object.form.ts`, previously 9) show the key ' + + 'for exactly that set.', + acceptanceCriteria: + 'Every field declaring `maxLength` carries a positive integer and is a bounded-string ' + + 'type. Well-formed declarations (a positive-integer `maxLength` on a bounded-string ' + + 'type) parse byte-identically to before; fields declaring no `maxLength` are ' + + 'untouched, and absence stays absence — no default materializes. Deleting a misplaced ' + + 'key changes no runtime behaviour (it sat outside the validator\'s bounded-string ' + + 'branch and enforced nothing). For a malformed value on a bounded-string type the ' + + 'author decides: re-declare the intended positive-integer bound (enforced by the ' + + 'write-time validator from the next write on, and honoured by schema drift as ' + + '`varchar(n)`), or delete the key and accept the type\'s unbounded/default column ' + + 'shape — either way the accidental old behaviour (empty-only writes under ' + + '`maxLength: 0`, unwritable fields under a negative value) is gone by decision, not ' + + 'by silence.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.field-min-length-malformed-or-misplaced-refused.ts b/packages/spec/src/migrations/entries/semantic/18.field-min-length-malformed-or-misplaced-refused.ts new file mode 100644 index 0000000000..d4804b992b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.field-min-length-malformed-or-misplaced-refused.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'field-min-length-malformed-or-misplaced-refused', + surface: 'object field `minLength` declarations — `minLength: 0`, negative or non-integer ' + + 'values on any type, and the key with any value on field types outside ' + + '`BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, ' + + '`json`, `secret`, …)', + replacement: 'a positive-integer `minLength` (>= 1) on a bounded-string field type — `text`, ' + + '`textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, ' + + '`signature`, `qrcode` (the twelve-member `BOUNDED_STRING_FIELD_TYPES` set; ' + + '`signature`/`qrcode` joined in #11875) ' + + '— or no declaration at all ("no minimum" is expressed by OMITTING the key, never by ' + + '`minLength: 0`). Deleting the key is mechanical and behaviour-preserving for a ' + + 'MISPLACED declaration (the write-time validator only ever applied `min_length` inside ' + + 'its bounded-string branch, so the key was inert by construction elsewhere) and for ' + + '`minLength: 0` / negative values anywhere (a string length is never below zero, so the ' + + 'check could not fire). A FRACTIONAL value on a bounded-string type is the judgment ' + + 'case: the validator\'s raw `<` comparison did consume it (`minLength: 2.5` behaved as ' + + '"at least 3"), so only the author knows the integer they MEANT — re-declare it ' + + 'deliberately if the constraint was wanted', + reason: + '#11949 (maintainer ruling 2026-08-25): `minLength` carried the exact defect pair #11566 ' + + 'closed for `maxLength`, and converges on the same template. Shape: the key was ' + + '`z.number()`, so `minLength: -5` and `minLength: 2.5` parsed cleanly while describing ' + + 'no character length; it is now `z.number().int().min(1)`. The lower bound is 1 by ' + + 'ruling: `minLength: 0` is refused loudly — a vacuous always-true declaration is ' + + 'exactly the noise an AI metadata author mass-produces, and the refusal surfaces it at ' + + 'authoring time. Applicability: the key sat on the BASE field schema — authorable on ' + + '`boolean` / `lookup` / `autonumber`, types where nothing bounded is stored — while the ' + + 'write-time validator (objectql `record-validator.ts`) only ever enforced it on the ' + + 'bounded-string set; the schema now refuses it outside `BOUNDED_STRING_FIELD_TYPES` ' + + '(ADR-0078 declared=enforced), and both authoring forms (`field.form.ts`, previously 3 ' + + 'types; `object.form.ts`, previously 9) show the key for exactly that set.', + acceptanceCriteria: + 'Every field declaring `minLength` carries a positive integer and is a bounded-string ' + + 'type. Well-formed declarations (a positive-integer `minLength` on a bounded-string ' + + 'type) parse byte-identically to before; fields declaring no `minLength` are untouched, ' + + 'and absence stays absence — no default materializes. Deleting a misplaced key or a ' + + '`0`/negative value changes no runtime behaviour (misplaced keys sat outside the ' + + 'validator\'s bounded-string branch; a `0`/negative bound could never fire). Deleting a ' + + 'fractional value on a bounded-string type relaxes the write seam by up to one ' + + 'character — the author decides whether to delete or re-declare the integer they ' + + 'meant; a wanted minimum is re-declared as a positive integer and enforced by the ' + + 'write-time validator from the next write on.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e372897c15..6035b55ba4 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5853,6 +5853,101 @@ const step18: MigrationStep = { + '`metadata_spec_invalid` and are refused on their next authoring-path save — re-declare ' + 'the field deliberately when that happens.', }, + { + id: 'field-max-length-malformed-or-misplaced-refused', + surface: 'object field `maxLength` declarations — `maxLength: 0`, negative or non-integer ' + + 'values on any type, and the key with any value on field types outside ' + + '`BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, ' + + '`json`, `secret`, …)', + replacement: 'a positive-integer `maxLength` (>= 1) on a bounded-string field type — `text`, ' + + '`textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, ' + + 'plus `signature`/`qrcode` since #11875 (the set is `BOUNDED_STRING_FIELD_TYPES`; the ' + + '#11566 narrowing itself landed on the ten-member set of its day) ' + + '— or no declaration at all. Deleting the key is mechanical and behaviour-preserving ' + + 'for a MISPLACED declaration: the write-time validator only ever applied `max_length` ' + + 'inside its bounded-string branch, so the key was inert by construction on every other ' + + 'type. A MALFORMED value on a bounded-string type is the judgment case — the ' + + 'validator\'s raw `>` comparison did consume it (`maxLength: 0` accepted only the ' + + 'empty string, a negative value refused every write, `maxLength: 12.5` behaved as ' + + '"at most 12"), and the SQL schema-drift planner consumed `maxLength: 0` as ' + + '`varchar(0)` DDL until #11431 taught it to defend itself — so only the author knows ' + + 'the bound they MEANT: re-declare it as a positive integer, or delete it deliberately ' + + 'accepting the unbounding', + reason: + '#11566 (maintainer ruling 2026-08-24; enforcement shipped on the 17.x line in PR ' + + '#11989 — accept-set narrowings ride minors, and this entry tells `migrate meta` users ' + + 'at the major boundary; registration was deferred to #11950 because the registry file ' + + 'was serialized behind an in-flight change when the enforcement landed). Shape: a ' + + 'character length is a positive integer, so the key tightened from `z.number()` to ' + + '`z.number().int().min(1)` — `maxLength: 0` measurably sent schema-drift planning ' + + '`varchar(0)` DDL no server accepts, at severity error/destructive, before #11431 ' + + 'taught that consumer to defend itself (the #8321 `precision`/`scale` house pattern). ' + + 'Applicability: the key sat on the BASE field schema — authorable on `boolean` / ' + + '`lookup` / `autonumber`, types where nothing bounded is stored — while the write-time ' + + 'validator (objectql `record-validator.ts`) only ever enforced it on its ten ' + + 'bounded-string types, the one list of the three that had a measured reader; that list ' + + 'is promoted to the protocol as `BOUNDED_STRING_FIELD_TYPES`, the schema refuses the ' + + 'key outside it (ADR-0078 declared=enforced), and both authoring forms ' + + '(`field.form.ts`, previously 3 types; `object.form.ts`, previously 9) show the key ' + + 'for exactly that set.', + acceptanceCriteria: + 'Every field declaring `maxLength` carries a positive integer and is a bounded-string ' + + 'type. Well-formed declarations (a positive-integer `maxLength` on a bounded-string ' + + 'type) parse byte-identically to before; fields declaring no `maxLength` are ' + + 'untouched, and absence stays absence — no default materializes. Deleting a misplaced ' + + 'key changes no runtime behaviour (it sat outside the validator\'s bounded-string ' + + 'branch and enforced nothing). For a malformed value on a bounded-string type the ' + + 'author decides: re-declare the intended positive-integer bound (enforced by the ' + + 'write-time validator from the next write on, and honoured by schema drift as ' + + '`varchar(n)`), or delete the key and accept the type\'s unbounded/default column ' + + 'shape — either way the accidental old behaviour (empty-only writes under ' + + '`maxLength: 0`, unwritable fields under a negative value) is gone by decision, not ' + + 'by silence.', + }, + { + id: 'field-min-length-malformed-or-misplaced-refused', + surface: 'object field `minLength` declarations — `minLength: 0`, negative or non-integer ' + + 'values on any type, and the key with any value on field types outside ' + + '`BOUNDED_STRING_FIELD_TYPES` (`boolean`, `lookup`, `autonumber`, `formula`, `select`, ' + + '`json`, `secret`, …)', + replacement: 'a positive-integer `minLength` (>= 1) on a bounded-string field type — `text`, ' + + '`textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code`, ' + + '`signature`, `qrcode` (the twelve-member `BOUNDED_STRING_FIELD_TYPES` set; ' + + '`signature`/`qrcode` joined in #11875) ' + + '— or no declaration at all ("no minimum" is expressed by OMITTING the key, never by ' + + '`minLength: 0`). Deleting the key is mechanical and behaviour-preserving for a ' + + 'MISPLACED declaration (the write-time validator only ever applied `min_length` inside ' + + 'its bounded-string branch, so the key was inert by construction elsewhere) and for ' + + '`minLength: 0` / negative values anywhere (a string length is never below zero, so the ' + + 'check could not fire). A FRACTIONAL value on a bounded-string type is the judgment ' + + 'case: the validator\'s raw `<` comparison did consume it (`minLength: 2.5` behaved as ' + + '"at least 3"), so only the author knows the integer they MEANT — re-declare it ' + + 'deliberately if the constraint was wanted', + reason: + '#11949 (maintainer ruling 2026-08-25): `minLength` carried the exact defect pair #11566 ' + + 'closed for `maxLength`, and converges on the same template. Shape: the key was ' + + '`z.number()`, so `minLength: -5` and `minLength: 2.5` parsed cleanly while describing ' + + 'no character length; it is now `z.number().int().min(1)`. The lower bound is 1 by ' + + 'ruling: `minLength: 0` is refused loudly — a vacuous always-true declaration is ' + + 'exactly the noise an AI metadata author mass-produces, and the refusal surfaces it at ' + + 'authoring time. Applicability: the key sat on the BASE field schema — authorable on ' + + '`boolean` / `lookup` / `autonumber`, types where nothing bounded is stored — while the ' + + 'write-time validator (objectql `record-validator.ts`) only ever enforced it on the ' + + 'bounded-string set; the schema now refuses it outside `BOUNDED_STRING_FIELD_TYPES` ' + + '(ADR-0078 declared=enforced), and both authoring forms (`field.form.ts`, previously 3 ' + + 'types; `object.form.ts`, previously 9) show the key for exactly that set.', + acceptanceCriteria: + 'Every field declaring `minLength` carries a positive integer and is a bounded-string ' + + 'type. Well-formed declarations (a positive-integer `minLength` on a bounded-string ' + + 'type) parse byte-identically to before; fields declaring no `minLength` are untouched, ' + + 'and absence stays absence — no default materializes. Deleting a misplaced key or a ' + + '`0`/negative value changes no runtime behaviour (misplaced keys sat outside the ' + + 'validator\'s bounded-string branch; a `0`/negative bound could never fire). Deleting a ' + + 'fractional value on a bounded-string type relaxes the write seam by up to one ' + + 'character — the author decides whether to delete or re-declare the integer they ' + + 'meant; a wanted minimum is re-declared as a positive integer and enforced by the ' + + 'write-time validator from the next write on.', + }, { id: 'field-scale-precision-integer-refused', surface: 'object field `scale` / `precision` declarations (`Field.number` and friends) — '