From f081ac6e8185a109ddf7040ef0b8aa2be1cb79c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 11:26:04 +0000 Subject: [PATCH 1/3] fix(spec): minLength is a positive integer, authorable only on bounded-string field types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV --- .changeset/minlength-bounded-string-only.md | 9 ++ content/docs/references/data/field.mdx | 2 +- .../src/validate-predicate-path-refs.test.ts | 11 +-- packages/spec/src/data/field.form.ts | 7 +- packages/spec/src/data/field.test.ts | 90 +++++++++++++++++++ packages/spec/src/data/field.zod.ts | 39 +++++++- packages/spec/src/data/object.form.ts | 5 +- ...n-length-malformed-or-misplaced-refused.ts | 46 ++++++++++ packages/spec/src/migrations/registry.ts | 42 +++++++++ 9 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 .changeset/minlength-bounded-string-only.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.field-min-length-malformed-or-misplaced-refused.ts diff --git a/.changeset/minlength-bounded-string-only.md b/.changeset/minlength-bounded-string-only.md new file mode 100644 index 0000000000..bfde3911dd --- /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` — 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 ten (`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 634977cbfc..766ea18c3d 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. | -| **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. | | **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 2ae7204359..799db0d9f3 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 ten 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 and the validator enforced it on 10. + { field: 'minLength', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code']", helpText: 'Minimum character length' }, // #11566 — `maxLength` is shown for exactly the ten 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 d4bd952b0d..caffba1a19 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -438,6 +438,96 @@ 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. + const wrongTypes = [ + 'boolean', 'number', 'date', 'select', 'lookup', 'autonumber', + 'formula', 'json', 'secret', + ] 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. + const ten = [ + 'text', 'textarea', 'email', 'url', 'phone', 'password', + 'markdown', 'html', 'richtext', 'code', + ] as const; + for (const type of ten) { + 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 e50f288863..9a8333cd5a 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). * * Deliberately NOT here: `secret` (stored ciphertext handle — the authored * value's length is not what the column holds), `select`/`multiselect` @@ -885,7 +886,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.'), - 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.'), /** Number Constraints */ // #8321 — `precision`/`scale` are digit COUNTS, so a non-integer or negative @@ -1660,6 +1668,29 @@ 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. + 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 — ` + + `'text', 'textarea', 'email', 'url', 'phone', 'password', 'markdown', 'html', ` + + `'richtext', 'code' — 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 7536373b57..992fa03778 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -127,7 +127,10 @@ export const objectForm = defineForm({ // write-time validator's list; maintainer ruling 2026-08-24). // `code` was the one this list was missing. { field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code']" }, - { 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 ten (maintainer ruling + // 2026-08-25: the #11566 template applies in full). `code` was the + // one this row was missing. + { field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code']" }, // 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-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..b76c0310ed --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.field-min-length-malformed-or-misplaced-refused.ts @@ -0,0 +1,46 @@ +// 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` ' + + '— 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..8d067a6e3f 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5853,6 +5853,48 @@ 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-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` ' + + '— 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) — ' From d15c716c40d28748744e52eebd28a198a36fc99c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 11:30:53 +0000 Subject: [PATCH 2/3] feat(spec): register the deferred #11566 maxLength narrowing in the ADR-0087 ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV --- .changeset/maxlength-adr0087-ledger-entry.md | 7 +++ ...x-length-malformed-or-misplaced-refused.ts | 53 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 49 +++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 .changeset/maxlength-adr0087-ledger-entry.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.field-max-length-malformed-or-misplaced-refused.ts 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/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..ff6d5e12fb --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.field-max-length-malformed-or-misplaced-refused.ts @@ -0,0 +1,53 @@ +// 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` ' + + '— 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/registry.ts b/packages/spec/src/migrations/registry.ts index 8d067a6e3f..a7830e7ae9 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5853,6 +5853,55 @@ 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` ' + + '— 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 ' From 1f68923470b2e47ded82cc71a14953bc0bf80123 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 11:44:11 +0000 Subject: [PATCH 3/3] 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 Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV --- content/docs/references/data/field.mdx | 2 +- packages/spec/src/migrations/registry.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) 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/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a7830e7ae9..6035b55ba4 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5860,7 +5860,9 @@ const step18: MigrationStep = { + '`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` ' + + '`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 ' @@ -5909,7 +5911,9 @@ const step18: MigrationStep = { + '`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` ' + + '`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 '