Skip to content

spec: maxLength is authorable on every field type and validated as no more than a number — maxLength: 0 and maxLength: 12.5 parse cleanly #11566

Description

@claude

Found while implementing #11431. Filed unassigned. Cannot be fixed from the driver lane — packages/spec is a different seat.

What

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

maxLength: z.number().optional().describe("Max character length"),

on the base field schema. Two consequences:

  1. No shape validation. .number() alone accepts 0, -5, 12.5 and Infinity. None of those is a character length, and each parses without complaint.
  2. Authorable on every type. Being on the base schema, maxLength is legal on boolean, date, lookup, autonumber, formula — types where it describes nothing that is stored.

Evidence that this is not theoretical

schema-drift.ts read a malformed bound as authoritative and planned DDL no server accepts — maxLength: 0 took the narrowing arm (0 > 255 is false) and asked for varchar(0), at severity error / category destructive, i.e. as work os migrate apply --allow-destructive should go do. Fixed on the driver side in #11431 by refusing anything that is not a positive integer, but that is a consumer defending itself against a producer that should not have emitted the value (PD #12).

The authoring forms already disagree about where the key applies, which suggests the base-schema placement was never deliberate:

  • field.form.ts shows maxLength for text / textarea / email only
  • object.form.ts shows it for text,textarea,email,url,phone,password,markdown,html,richtext

Neither list matches the other, and neither matches the base schema's "every type".

The write-time validator (packages/objectql/src/validation/record-validator.ts) enforces maxLength for text | textarea | email | url | phone | password | markdown | html | richtext | code — a third list, and the closest thing to a real answer for which types the key means something.

Direction (for triage — not prescribing)

Two separable decisions:


Generated by Claude Code

Activity

  1. added theissue type on Aug 24, 2026
  2. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    分诊(Routine 席,小时轮):入决策箱,needs-user-decision + domain:spec,type Bug(校验缺口本体是缺陷,但两个修法都收窄接受集 = 契约变化,人工地板)。下游注记:#11431(engine,pm:blocked)的 Blocked-by 指向本卡,裁决落地即解锁它。标准四棱块:

    os-decision-facets

    ① 实际业务需求:maxLength: 0 已实测把 schema-drift 推到「计划一个没有服务器接受的 varchar(0) DDL、且标记为 destructive」——这是真实的错误放大链,不是理论洞。写侧校验器的十类型清单就是今天真实在读这个键的消费者。

    ② 项目长远合理性:形状收紧(z.number().int().min(1))与同文件 precision 的既有 house pattern 完全同构,是把已声明意图兑现;适用面收窄(哪些类型允许 maxLength)触 ADR-0078 declared=enforced,且三份清单(field.form / object.form / record-validator)互不一致——收敛到校验器的十类型清单是唯一有实测读者背书的答案。

    ③ 防 AI 犯错:这是四轴里最强的一条:maxLength 现在「每个类型都可声明、什么形状都能过」,AI 作者在 boolean/lookup 上写 maxLength 得不到任何响声。publish 时响亮拒绝优于驱动侧逐个防御(#11431 已被迫在消费端防了一次)。

    ④ 创业阶段不扩散需求:两个修法都是收窄不是扩张,成本一次性;不裁的成本是每个驱动/工具各自防御一遍。

    推荐:形状收紧(int ≥1)+ 适用面收敛到 record-validator 的十类型清单,一次裁掉两问;实施走条款②契约复审档位,并同笔对齐 field.form/object.form 两份表单清单。 本分析看不见的:存量已发布 app 里是否有踩线声明(如小数/0/挂在非字符串类型上)——若维护者知道存在,裁决需附迁移/宽限条款。


    Generated by Claude Code

  3. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded — tighten both halves

    Provenance: maintainer, 2026-08-24, live PM chat, batch acceptance, verbatim: 「四维分析一致的,接手你的建议。」 The four-facet block above is aligned, so the recommendation is adopted.

    Ruled: shape tightens to the house pattern (z.number().int().min(1), per the precision precedent one field below), and applicability converges to the write-time validator's ten-type list (text | textarea | email | url | phone | password | markdown | html | richtext | code) — maxLength refused on types that store no bounded string. The two authoring-form lists (field.form.ts, object.form.ts) are aligned to the same ten in the same stroke. Clause-②: yes (narrows the accept set) — contract-review tier applies at dispatch.

    State: needs-user-decision → pm:queue (spec lane). Downstream: #11431's Blocked-by: #11566 unlocks when this closes; the engine seat should re-verify that card's surface on the post-landing ref per the unlock discipline.


    Generated by Claude Code

  4. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Claim: PM loop round R5 wave 1
    Session: session_01K93P8PbH7kVjsAXkqzH1zY
    Branch: claude/issue-11566-maxlength-shape-applicability
    Worktree: objectstack-issue-11566
    Domain: domain:spec
    File surface: packages/spec/src/data/field.zod.ts (base-schema maxLength at :856 + the length/size alias map at :738), packages/spec/src/data/field.form.ts, packages/spec/src/data/object.form.ts, new rejection pins + changeset (stop on breach; explain in the report). ⛔ packages/spec/src/migrations/registry.ts is HELD by the #10724/#11169 family dispatch this wave — if this tightening's conventions force a touch there, stop and report rather than editing it.
    Container & model: M, mode:subagent, model: claude-fable-5 — dispatch-gates --tier packages/spec/src/data/field.zod.ts (this dispatch): "Clause ② SUSPECT surface — packages/spec/src/** — the contract surface … the normal landing zone of a clause-② card"; content reading is the verdict: the 2026-08-24 ruling narrows the accept set ⇒ fable-mandatory.
    Clause-②: yes
    Serial constraints cleared: same-file predecessor PR #11903 (deleteBehavior, the .overwrite() region) merged 2026-08-25 — same-day churn clause applies, work from current merged main. H17 hit named per protocol: hold #8345 declares field.zod.ts as a Restart-touch trigger file — notification only, no rider work taken from it. In-flight overlap: none (lane had 0 dispatched cards at fire); lane sibling #11408 (editMode, may touch object.form.ts) is deliberately held to wave 2 behind this card — cross-wave serial, not a fold (different defect shape, fails family gate ①). Cross-lane: #11739 (services, feat(spec,auth)) touches spec on the auth-posture surface, disjoint from this file set.


    Generated by Claude Code

  5. claude commented on Aug 25, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 11566,
      "status": "done",
      "branch": "claude/issue-11566-maxlength-shape-applicability",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11989",
      "premise_still_valid": true,
      "summary": "Ruled tightening implemented on post-#11903 main (base ac59744): maxLength is z.number().int().min(1); a new superRefine refuses it outside the write-time validator's ten types, exported as BOUNDED_STRING_FIELD_TYPES; both authoring-form maxLength rows converge on the same ten; 15 new pins assert specific zod issue code+path+message. Alias-map assumption measured false-but-safe: strictObject aliases are unknown-key rejection guidance only — length:/size: never flow a value into maxLength, so no bypass exists. Repo sweep found zero spec-parsed authors of now-rejected values; driver-sql's #11431 consumer-defense fixtures bypass FieldSchema.parse and are preserved verbatim.",
      "tests": "All at head e36b89a (re-affirmed after the lint census commit; initial readings at 9ca3a81, tree byte-identical for spec surfaces). spec field.test.ts: 'Tests  184 passed (184)'. Full spec suite: 'Test Files  424 passed (424)' / 'Tests  11288 passed (11288)'; spec typecheck exit 0 incl 'check:test-typecheck: OK'. Reverse verification from committed state (git restore --source=origin/main, mutation grep-confirmed: BOUNDED_STRING_FIELD_TYPES count 0; no dist in the loop — spec tests import ./field.zod source, so no rebuild leg applies; restore leg grep count 4 + git status clean vs HEAD): exactly the predicted 12 reds ('Failed Tests 12' = 3 shape + 9 applicability pins), direction red-on-revert as expected. Consumers: objectql 'Test Files  232 passed (232)' / 'Tests  4113 passed (4113)'; driver-sql 'Test Files  128 passed | 8 skipped (136)' / 'Tests  1981 passed | 114 skipped (2095)' (live PG/MySQL matrix skips, no DB); lint 'Test Files  81 passed (81)' / 'Tests  2294 passed (2294)' after the census re-derivation.",
      "gates": [
        "check:generated --fix: proved exactly 3 stale (api-surface, export-origins, docs) and regenerated them; 'check:authorable-surface ... ✓', 'check:skill-refs ... ✓' (no skills/** touch)",
        "check-adr-0087-registration: '✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).'",
        "check-changeset-no-major: '✓ This diff introduces no major bump.'",
        "check-empty-changeset: '✓ No empty-frontmatter changeset introduced by this diff'",
        "check:i18n: 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).' (after building the CLI; first run refused loudly with nothing measured)",
        "check:cross-package-test-inputs: 'OK: 16 package(s) read outside themselves, all declared'",
        "check:where-matcher / check:query-options-erasure / check:engine-double-contract: 'baseline key set verified against ac59744: no files added.'",
        "check:spec-parsed-alias: 'ADR-0122 type-alias convention ... OK'",
        "check-nul-bytes: 'OK (scanned 6651 text file(s) ... no raw ASCII control bytes).'",
        "dispatch-gates re-derived from actual diff, derivation stamped 'at commit 9ca3a81' then re-run at e36b89a (37 matched families placed); declared narrowing: repo-wide pnpm lint and check:type-check-debt --re-measure left to CI — spec's own typecheck+test-typecheck accounting ran green locally",
        "CI at 9ca3a81: Test Core red on lint's RHS-rule corpus census (expected 47, got 44) — fixed in e36b89a by re-deriving the census (the pin's own anchor note places in-list literals outside the rule); full lint suite green locally at that head"
      ],
      "deviations": [
        "ADR-0087 ledger entry deferred to #11950: registry.ts is HELD by the #10724/#11169 family dispatch per the card constraint; the changeset carries no **BREAKING** marker (matching the #9689 accept-set-narrowing precedent), so the disposition gate does not fire — confirmed by its own verdict line above. The convention did NOT mechanically force a registry touch, so I did not stop-and-block; the debt is tracked openly instead of landing silently. PM may re-judge this fork.",
        "record-validator NOT rewired to import BOUNDED_STRING_FIELD_TYPES (outside the claimed file surface; membership identical and pinned on both sides) — possible follow-up refactor, noted in PR body.",
        "field.form minLength row left at 3 types / object.form minLength at 9 (ruled scope covered maxLength only); the minLength defect pair is filed as #11949.",
        "Report fields are the union of the dispatch's machine-checkable list and the standard template (the two specs differ; conflict noted per protocol).",
        "Second commit e36b89a (lint census) landed after the PR opened, in response to the PM's CI notification — same card scope, no widening."
      ],
      "files_changed": [
        ".changeset/maxlength-bounded-string-only.md",
        "content/docs/references/data/field.mdx",
        "packages/spec/api-surface/data.json",
        "packages/spec/export-origins/data.json",
        "packages/spec/src/data/field.form.ts",
        "packages/spec/src/data/field.test.ts",
        "packages/spec/src/data/field.zod.ts",
        "packages/spec/src/data/object.form.ts",
        "packages/lint/src/validate-predicate-path-refs.test.ts"
      ],
      "open_questions": [
        {
          "question": "Should the #11950 ledger entry land as soon as the registry hold lifts (this wave) or ride the next spec seat round?",
          "options": [
            "A: next round, PM-scheduled",
            "B: rider on the #10724/#11169 family PR"
          ],
          "recommendation": "A — a rider on the family PR widens their scope; the entry is inert until protocol major 18 per step18's own text, so there is no urgency beyond tracking."
        }
      ],
      "out_of_scope_findings": [
        "filed as #11949: minLength carries the identical pre-#11566 defect pair (shape + base-schema applicability + stale form lists); lower bound (0 vs 1) is a judgment call",
        "filed as #11950: deferred ADR-0087 ledger entry for this narrowing (registry.ts held this wave)"
      ]
    }

    Generated by Claude Code

  6. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    ACCEPT + Contract review: PASS — PR #11989 at head e36b89a (spec seat R5, reviewer of record session_01K93P8PbH7kVjsAXkqzH1zY).

    Checklist verdict, verified against GitHub and my own reads:

    Disposition: enqueue follows the standing rule — every check on the PR green at head e36b89a; the seat flips ready + queues on the all-green reading and tracks to MERGED. Card stays pm:dispatched until landing.


    Generated by Claude Code

  7. removed their assignment
    on Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions