Skip to content

fix(fields): honour an authored max_length on every rich-content field (objectui#8438) - #8768

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8438-richtext-maxlength-guard
Sep 9, 2026
Merged

fix(fields): honour an authored max_length on every rich-content field (objectui#8438)#8768
os-zhuang merged 1 commit into
mainfrom
claude/issue-8438-richtext-maxlength-guard

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #8438

What this is

markdown, html and richtext are three registry keys served by ONE widget, RichTextField. That widget read maxLength / max_length nowhere, while buildValidationRules — which has no field-type gate — compiled the same key into a react-hook-form rule for every field. So a cap authored on any of the three was enforced at submit and invisible before then: no native stop, no character counter, nothing named in aria-describedby. The person is told the limit only after writing the text.

The card's two sites were re-measured, and neither could carry the cap

The card is filed as "richtext is missing from ObjectForm's maxLength guard and EmbeddableForm's DEFAULT_MAX_LENGTH". Both premises were tested on this branch's base (e3fb3b6) by rendering a form with the four long-text types and reading the DOM:

type native maxlength counter
textarea, max_length: 111 111 0/111
markdown, max_length: 222 ABSENT none
html, max_length: 333 ABSENT none
richtext, max_length: 444 ABSENT none

⇒ the card's "two of the three get the cap" does not hold: none of the three did. The two literals markdown and html sitting in ObjectForm's guard were inert, and so was markdown: 5000 / html: 5000 in EmbeddableForm — the card's line "a public form's richtext field takes unbounded input where its markdown twin stops at 5000" is false in its second half. The markdown twin did not stop at 5000 either.

Then the guard itself was ablated — the whole formField.maxLength / formField.minLength assignment replaced by a comment — and the rendered output was byte-identical, on both the registered-widget path and the builtin path. formField is the FORM FIELD; the metadata carrier a registered widget reads is formField.field, a different object, so that assignment has never reached a registered widget for any of the four types it names.

⇒ Adding 'richtext' to that list — literally OR derived — would have changed nothing observable. That is exactly the false-green form objectui#4250 named and objectui#4831 warned about, arrived at from the other direction.

The list question, answered

The routing constraint on this card was to answer objectui#4831's question ("should the list become every type that reaches the long-text widget rather than hand-written literals?") rather than patch two literals silently. Both halves are answered, differently, because measurement says the two sites are not the same kind of thing:

  • EmbeddableForm — DERIVED. This repo already has THE table: RICH_TEXT_CELL_RENDERERS in packages/fields/src/widgets/richTextDisplay.tsx, whose docblock says "THE table, singular … there is no second copy left to forget", with objectui#5498 precedent for consumers spreading it. Its key set is now published as RICH_TEXT_FIELD_TYPES and DEFAULT_MAX_LENGTH spreads it. That table no longer names a rich-content type, so it can no longer omit one. The four short-text entries stay literal on purpose — each is a distinct widget with a distinct cap and no table to derive from; the root cause being removed is "one widget, N keys, a list that knows N-1".
  • ObjectForm — NEITHER. A list that reaches nothing does not get a fourth member. The block is left exactly as it was (it IS live for the other form-field producer, where the form field is the carrier) with the ablation recorded in a comment at the site, so the fifth instance does not get patched there either.
  • The real defect is at the widget. RichTextField now dual-reads maxLength ?? max_length off its carrier — the same read TextAreaField has carried since framework#1878 §3 — and forwards it to the native stop, the shared CharacterCount, and the aria-describedby wiring, on the inline surface and the fullscreen dialog. Three keys, one widget, one read: this is the derived answer, taken one level below the lists.

Pinned by behaviour, and the ablations

packages/plugin-form/src/richtextMaxLength.8438.test.tsx, 10 tests. Nothing asserts that a literal is in a list. Every assertion reads the DOM: the native stop, the counter's digits, and the text of the nodes the control NAMES in aria-describedby (ids resolved, not string-matched). markdown and html are asserted beside richtext because they are the same widget. Two negative controls: a rich field with no authored cap gets no stop and no counter, and a type outside the table gets no default.

Two one-shot ablations, each mutating a committed file, proving the mutation landed on disk by anchor count and blob hash, and restoring via git checkout HEAD -- verified by an identical blob hash:

  • A — delete the maxLength dual-read in RichTextFieldTests 6 failed | 4 passed (10). All three ObjectForm cap tests AND all three EmbeddableForm default-cap tests turn red together; the four that stay green are the two controls, the textarea sibling and the authored-cap-wins unit, none of which depend on the read.
  • B — delete richtext from RICH_TEXT_CELL_RENDERERSTests 1 failed | 9 passed (10), and the one is richtext: an uncapped public-form field is capped at the long-text default.

⚠️ On the acceptance criterion as written. The triage asked that removing 'richtext' from either list turn both tests red. After this change there is no 'richtext' literal in either list to remove — that is the point of the route — so the criterion is met in its stronger form instead: ablation A is a single deletion that reddens both halves at once, and ablation B shows the EmbeddableForm half is genuinely carried by the derivation and not by something incidental.

Behaviour change, stated

  • A markdown / html / richtext field that already declares max_length (or maxLength) now shows a counter and stops at the cap. Before, it silently accepted the overflow and failed on submit. A field with no authored cap is unchanged.
  • In EmbeddableForm, a public form's richtext field is now capped at the 5000 long-text default like its two siblings.
  • Both changes NARROW what reaches the user, and both are confined to exactly the three keys of this one widget.

⚠️ Contract note for the reviewer

The dispatch's claim recorded Clause-②: no with the rationale "no accept-set widening and no new published surface". The derived route needs one: @object-ui/fields gains RICH_TEXT_FIELD_TYPES (a readonly string array) and re-exports the RichTextFieldType union. Purely additive, no removal, no signature change — but it IS new published surface, so the claim's rationale is not fully true of the landed change and that is flagged here rather than absorbed. packages/types/src/field-types.ts is also edited (comment only): its RichtextFieldMetadata docblock asserted that these two form-side sites "are not corrected here", which this change makes false, and both of its readings were wrong besides.

Gates run locally

scripts/pm/dispatch-gates.mjs does not exist in this repo, so the family was derived by hand from the changed-file surface. Commands, and the verdict line each printed:

gate exit verdict
pnpm --filter @object-ui/fields test 0 Test Files 149 passed (149) · Tests 2546 passed (2546)
pnpm --filter @object-ui/plugin-form test 0 Test Files 87 passed (87) · Tests 871 passed | 1 skipped (872)
pnpm --filter @object-ui/{fields,plugin-form,types} run type-check 0, 0, 0 clean (after building the dependency closure — before that all three reported TS2307 Cannot find module, i.e. NOT MEASURED)
pnpm --filter @object-ui/{fields,plugin-form,types} lint 0, 0, 0 0 errors (953 / 815 / 266 pre-existing warnings)
pnpm check:control-bytes 0 OK
pnpm check:readme-exports 0 OK … 0 unbuilt, 0 fabricated, 0 stale omission(s) — re-run after turbo run build --filter='./packages/*'; the first run reported the population COLLAPSED, i.e. NOT MEASURED
pnpm check:dist-completeness 0 12 package(s) complete (1637 emitted files verified)
pnpm check:esm-specifiers 0 no un-ledgered package emits an extensionless relative specifier
pnpm check:vi-mock-specifiers · check:vi-mock-inherit 0, 0 OK
pnpm check:self-import · check:unused-deps · check:phantom-deps 0, 0, 0 OK
pnpm check:i18n-keys · check:i18n-drift · check:i18n-dead-keys 0, 0, 0 OK
pnpm check:unreferenced-sources · check:designer-field-key-parity · check:side-effects-array 0, 0, 0 OK

Derivation, stated so it can be checked: new exports on a published barrel pulled in readme-exports / dist-completeness / esm-specifiers; a new cross-package import pulled in unused-deps / phantom-deps / self-import; a new test file pulled in the two vi-mock gates; any edit pulled in control-bytes; the widget edit touches i18n call sites' neighbourhood, so the three i18n gates were run. Repo-wide scans (pnpm lint at the root, the doc gates, the e2e suites) are CI's, not this branch's, and were not run here. DESIGNER_FIELD_TYPES was measured and excluded by the card and is untouched.

Reads of the card and its comments went through the zero-quota web payload channel and unauthenticated repo-scoped REST; both were probed before use and both returned 200. No sanitizer truncation was found in the issue body or in either comment: the tag-shaped fragments in them survived intact in storage, and both comments end with a complete attribution footer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH


Generated by Claude Code

…d (objectui#8438)

`markdown`, `html` and `richtext` are three registry keys served by ONE widget,
`RichTextField` — and that widget read `maxLength` / `max_length` nowhere, while
`buildValidationRules` (no field-type gate) compiled the same key into a
react-hook-form rule for every field. A cap authored on any of the three was
therefore enforced at SUBMIT and invisible before then: no native stop, no
character counter, nothing named in `aria-describedby`.

The card was filed as "`richtext` is missing from ObjectForm's maxLength guard
and EmbeddableForm's DEFAULT_MAX_LENGTH". Re-measured on the branch base,
neither list could have carried the cap:

  - `ObjectForm`'s guard writes `formField.maxLength`, but a registered widget's
    metadata carrier is `formField.field` — a different object. Ablating that
    assignment changed no rendered attribute for any of its four types, on
    either the registered or the builtin render path. Left in place (it is live
    for the other form-field producer) with the measurement recorded at the site.
  - `EmbeddableForm`'s `DEFAULT_MAX_LENGTH` did deliver 5000 for `markdown` and
    `html`; `RichTextField` then dropped it unread.

So the cap was lost for all three keys, not for `richtext` alone. `RichTextField`
now dual-reads `maxLength ?? max_length` off its carrier — the same read
`TextAreaField` has carried since framework#1878 §3 — and forwards it to the
native stop, the `CharacterCount` counter and the `aria-describedby` wiring, on
both the inline surface and the fullscreen dialog.

The list question objectui#4831 asked and its fix declined to answer is answered
here: `RICH_TEXT_FIELD_TYPES` is derived from `RICH_TEXT_CELL_RENDERERS` — THE
table — and published, and `EmbeddableForm`'s cap table spreads it instead of
naming rich-content types. A list that names no member can omit none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3483.6 KB 3512.7 KB
Main entry chunk (gzip) 144.0 KB 350 KB
Entry file index-CkTfhaTF.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 499.42KB 114.32KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 245.88KB 62.05KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 252.39KB 65.32KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 134.25KB 33.52KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 210.67KB 57.19KB
plugin-kanban (index.js) 55.50KB 15.75KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Director seat adoption record — POST-MERGE audit — summon #20, session_01Tep4AYXZvyBA7jsvne5KZV (os-bill), 2026-09-09T07:06Z. This PR merged at 05:36:33Z (c03d03bb2d) after an in-seat review (card comment 5596181770) that ran at TIER_DEFAULT (opus) under a "quota-exhaustion exemption"; references/contract-review.md line 60 confines that exemption to dispatch, never to the contract review, so the merged head had no verdict at CONTRACT_REVIEW_TIER until now. The downgrade was disclosed by the seat up front; this audit is the compensating control, not a reproach. The verdict below is the record at the mandated tier: adopted verbatim from an isolated contract-review subagent (explicit model = CONTRACT_REVIEW_TIER). Transcript tier check before adoption: every harness-stamped model field in the subagent transcript reads claude-fable-5-1 (89 stamps, no other value). Merged head re-read at posting time = 3b991c926f.


Contract review (CONTRACT_REVIEW_TIER, isolated seat, POST-MERGE) — objectui PR #8768 @ 3b991c926ff77e95c14dda7ea51a9b02555b6f74

Verdict: PASS WITH FINDINGS

No follow-up card is owed for the code. Every finding below is non-blocking; two are process records (tier, dual carrier) that this review closes by being the mandated-tier verdict, and one is a source-comment accuracy correction.

Ruling conformance

Rule Reading Status
Increment = landed change Squash merge: c03d03bb2d has one parent (efbd566d), PR head not an ancestor. git diff c03d03bb2d^1 c03d03bb2d is byte-identical (ignoring index lines) to git diff e3fb3b62(merge-base) refs/pr-review/8768. 8 files, +515/−13.
Card routing constraint — answer the list question, never add 'richtext' twice EmbeddableForm: derived (spreads RICH_TEXT_FIELD_TYPES). ObjectForm: unchanged, comment records why. No 'richtext' literal added anywhere.
Card pin rule — by behaviour, not by list membership richtextMaxLength.8438.test.tsx: every assertion reads DOM (maxlength attr, counter text, aria-describedby resolved to node text). The only two list-symbol mentions are in the docblock (lines 24, 35).
Acceptance literal "removing 'richtext' from either list turns both red" Unsatisfiable as written (no literal exists post-change). PR substitutes ablations A (widget read) and B (table key); stated, not absorbed. Accepted as the stronger form. ✅ (declared)
Claim file surface packages/types/src/field-types.ts was outside both lists; edited comment-only, declared as a deviation. ✅ (declared)
Clause-② declaration Claim said no ("no new published surface"); diff adds barrel exports ⇒ yes mandatory under the directional rule. Seat corrected at 5596159712. ✅ corrected — see F2 for completeness
Contract-review tier In-seat review 5596181770 ran at opus (TIER_DEFAULT) under a "quota-exhaustion exemption". objectstack:.claude/skills/pm-dispatch/references/contract-review.md:60: 「契约复核 ⛔ 不适用额度耗尽豁免降档:豁免对象是派发」. The merged head had no mandated-tier verdict until this one. ⚠️ F1
Dual carrier (2026-08-22 ruling: label on BOTH carriers) Card #8438 events: needs:contract-review labeled 05:09:50Z, unlabeled 05:13:43Z. PR #8768 events: never labeled. ⚠️ F2
Governed surface node scripts/check-governed-queue-guard.mjs --test <8 paths>NOT GOVERNED. No docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md.
Changeset presence .changeset/8438-richtext-maxlength-visible.md present; Changeset Declaration / Bump Policy / Fixed Group / Overwrite checks green.
Release-notes prohibition content/docs/releases/ untouched.

Derived judgments

(a) Published barrel of @object-ui/fields. One new line at packages/fields/src/index.tsx:2528: export { RICH_TEXT_FIELD_TYPES, type RichTextFieldType } from './widgets/richTextDisplay.js';. Neither name was on the barrel at the base parent (grep on efbd566d = 0 hits). Shapes: RICH_TEXT_FIELD_TYPES: readonly RichTextFieldType[] = Object.keys(RICH_TEXT_CELL_RENDERERS) as RichTextFieldType[] (richTextDisplay.tsx:199-201), value ['markdown','html','richtext']; RichTextFieldType = keyof typeof RICH_TEXT_CELL_RENDERERS (line 174, pre-existing at module level). RICH_TEXT_CELL_RENDERERS, richTextSyntax, richTextCellRenderer remain unpublished. Consumers at merge: RICH_TEXT_FIELD_TYPESpackages/plugin-form/src/EmbeddableForm.tsx:27,101 only; RichTextFieldType → none outside its module. The seat's Point-1 argument checks out: packages/fields/package.json exports has no deep subpaths, so a sibling package cannot reach the table except via the barrel. plugin-form already depended on @object-ui/fields (5 prior imports) — no new edge, no phantom dep.

(b) RichTextField and the three keys. packages/fields/src/index.tsx:3059-3061 resolves markdown/html/richtext to the one RichTextField; the widget discriminates on type only for display (resolveRichTextFieldType, richTextSyntax). The new read at RichTextField.tsx:382(field as { maxLength?: number }).maxLength ?? richField?.max_length — sits above the type discrimination, so behaviour is identical for all three keys by construction. What happens at the cap: the value is forwarded as native maxLength on the <Textarea> (:146, both inline and dialog — Textarea is a plain React.ComponentProps<'textarea'> spread), so the browser refuses further input; nothing truncates a pre-existing over-long value, CharacterCount reports the honest n of max and clamps the near-limit status to 0 left; the submit-time rule from buildValidationRules (index.tsx:2889, no type gate) is unchanged and still rejects over-long stored values. Consistency with the sibling: line-for-line the same as TextAreaField.tsx:80,91-93,122-125,136-158,190-240 — same dual read, same useId pair, same CharacterCount with announceNearLimit inline / false in the dialog, same appended-vs-assigned aria-describedby split, same footer={(draft)=>…} render-prop (FullscreenFieldEditor passes footer through). Only the testId strings and font-mono differ.

(c) The two lists. EmbeddableForm DEFAULT_MAX_LENGTH (:95-102): markdown: 5000, html: 5000 literals replaced by ...Object.fromEntries(RICH_TEXT_FIELD_TYPES.map(t => [t, LONG_TEXT_MAX_LENGTH])); four short-text literals kept, textarea now via the named LONG_TEXT_MAX_LENGTH. ObjectForm guard (:877-884): code unchanged (still text | textarea | markdown | html), 27-line comment added above it. No third list exists; git grep RICH_TEXT_FIELD_TYPES shows exactly one consumer. The derived route lands in one of the two sites; the other is deliberately left as-is — see F3 for the accuracy of the comment justifying that.

(d) packages/types/src/field-types.ts. 18 changed lines; filtering out lines beginning * / /** / */ leaves zero — comment-only, no runtime or type change. The rewritten docblock (:348-359) is now consistent with the landed behaviour.

(e) Surface delta. New exports: 1 runtime value + 1 type (above). Removed exports: none. package.json of fields/plugin-form/types: untouched (no exports/sideEffects/files movement). New keys on published payloads: none — applyDefaultMaxLengths now also stamps maxLength: 5000 onto a richtext customField, but that is the in-memory FormField config (same key it already stamped on markdown/html), not a wire payload; the submit payload is unchanged.

Semver / changeset

.changeset/8438-richtext-maxlength-visible.md: '@object-ui/fields': minor, '@object-ui/plugin-form': patch. No major (AGENTS.md:240 rule; check-changeset-no-major green).

  • fields: minorright: a new published export is additive surface.
  • plugin-form: patch — defensible: a bug fix whose behaviour change (public-form richtext now capped at 5000 like its siblings; authored caps now visible) is stated in the body. It narrows accepted input on public forms, which some would call minor; moot for versioning since the 39-package fixed group takes the max level (minor) anyway.
  • @object-ui/typessrc/ moved (comment-only) and is not named in the frontmatter. scripts/check-changeset-presence.mjs is a presence gate ("an EMPTY frontmatter counts"), not a per-package naming gate, so this is compliant, and a comment-only change warrants no bump. Note only (F7).
  • Governed paths in the diff: none.

Boundary flags

  • Clause-②: yes (published surface grows). The seat's correction is right. It is incomplete on the carrier side: the label was placed on the card only, never on PR fix(fields): honour an authored max_length on every rich-content field (objectui#8438) #8768 (F2).
  • The mechanical widening-tell gate is blind to this class in objectui. node scripts/pm/check-widening-tells.mjs --declaration no --diff landed.diffno widening tell (exit 0): T3 reads only packages/spec/api-surface/*.json, which objectui does not have, so a barrel export {…} is invisible to it. The false no was caught by the dev's self-report, not by the gate (F4). check-clause2-carriers.mjs --pair 8768 exits 2 post-merge (PR not open) — no reading, as expected.
  • Cross-package: new @object-ui/fields@object-ui/plugin-form import edge is pre-existing; no cycle (richTextDisplay imports nothing from plugin-form).
  • Bundle: Bundle Analysis and Console Performance Budget green; fields (index.js) 62.05 KB gzip.

Findings

  • F1 (non-blocking, process record) — no mandated-tier verdict at merge. Card comment 5596181770 applied the quota-exhaustion exemption to the contract review; contract-review.md:60 restricts that exemption to dispatch. The landed note 5596421332 records the downgrade honestly. This review supplies the missing verdict; nothing to fix in code.
  • F2 (non-blocking, process record) — dual carrier not honoured. needs:contract-review was applied to finding(plugin-form): richtext is missing from ObjectForm's maxLength guard and EmbeddableForm's DEFAULT_MAX_LENGTH — a max_length on a richtext field is enforced at SUBMIT but invisible in the UI, and uncapped by default #8438 (05:09:50Z) and never to fix(fields): honour an authored max_length on every rich-content field (objectui#8438) #8768 (PR events: only package: types, plugin, tests, package: fields, then ready_for_review 05:20:04Z). The 2026-08-22 ruling puts it on both. Moot for a merged PR; recorded for the round report.
  • F3 (non-blocking, source-comment accuracy) — packages/plugin-form/src/ObjectForm.tsx:850-863 overclaims. The comment states the guard "reaches nothing, for every one of the four types it names" and "Ablation, both configurations … byte-identical". Static trace at the merged head says the guard IS live for this producer in the no-registration configuration: mapFieldTypeToFormType('textarea') = 'field:textarea' (field-type-alias.ts:111); with field:textarea unregistered, renderFieldComponent (form.tsx:3647-3655) finds no widget, switch (type) matches no case and falls to default: (form.tsx:3892), which reads (fieldProps as any).maxLength (:4001) and renders <Input maxLength> (:4030) — and fieldProps carries exactly the key the guard writes (maxLength is not pulled off in the form.tsx:2292-2310 destructure and rides through the :2791 spread). The claim holds on the registered-widget path (widgets read the field carrier; toDomProps does not forward maxLength), which is the production configuration. Not executed here. Suggested docs-only touch: narrow the comment to "the registered-widget path". No runtime effect; the comment already says leave the guard in place.
  • F4 (non-blocking, out of scope for this card) — gate gap. check-widening-tells.mjs T3 cannot see objectui barrel exports (no api-surface ledger). Worth an objectstack note; not this PR's defect.
  • F5 (non-blocking) — README. packages/fields/README.md has no mention of RICH_TEXT_FIELD_TYPES; check:readme-exports only checks that README-cited names exist (not the reverse), so no gate is owed. Optional docs follow-up.
  • F6 (non-blocking, nit) — RichTextFieldType re-export has zero external consumers. Justified as the element type of the published array (otherwise unnameable by consumers); acceptable minimal growth.
  • F7 (non-blocking) — @object-ui/types not named in the changeset for a comment-only src/ edit. Compliant with the presence gate; moot in the fixed group.
  • F8 (non-blocking, confirmation) — test file honesty. No assertion reads a source list; negative controls present (uncapped rich field: no maxlength, 0 counters, no charcount in aria-describedby; number/json get no default). describedText resolves ids rather than string-matching; its tolerance of dangling ids is stated and is the FormControl upstream-parity note the dev left un-filed, correctly.

Dev report open_questions / deviations, answered:

  • OQ1 (apply needs:contract-review?) — A is right; the seat applied it on the card. Complete only when both carriers carry it (F2).
  • D1 (new published surface vs claim's no) — correctly flagged, not absorbed; Clause-②: yes stands.
  • D2 (file surface +1, field-types.ts) — verified comment-only; the "a published statement my change falsifies is mine to fix" ground holds.
  • D3 (scope shape: fix in RichTextField) — not creep; the card's own acceptance criterion (native attribute + counter on a richtext field) is unreachable from either list because the widget never read the key. Verified by static trace of the carrier split (form.tsx:2796 field: field.field || field).
  • D4 (ObjectForm list left without richtext) — correct outcome; the recorded justification is over-broad (F3).

CI at merge

Head 3b991c926f: 35 check runs, 34 latest-per-name — 31 success, 3 skipped, 0 failure/cancelled. Skipped: Test (coverage shard ${{ matrix.shard }}/4), Test (coverage), dependabot (all workflow-conditional, not gates). Green includes Type Check, Lint, Test (shard 1-4/4), Build & E2E, Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Governed Surface Queue Guard, README Export Check, Bundle Analysis, Control Byte Scan, Inert vi.mock Specifier Check. Combined status: success (Vercel). Merged 2026-09-09T05:36:33Z by os-zhuang as c03d03bb2d.

Implemented-by: branch claude/issue-8438-richtext-maxlength-guard (merged as c03d03bb2d)
Reviewed-by: director seat summon #20 (isolated fable subagent, transcript-verified before adoption)

{"pr":8768,"repo":"objectui","head":"3b991c926ff77e95c14dda7ea51a9b02555b6f74","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"34 latest-per-name: 31 success, 3 skipped (Test (coverage shard), Test (coverage), dependabot), 0 failed; combined status success"}


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants