Repository navigation
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
Description
Activity
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Sep 8, 2026 分诊路由 —
domain:ui·pm:queue·priority:p2· type Bug⛔ 本席是分诊席(
claude-opus-5):不认领、不派发、不写码、不合并、不裁决决策箱卡。以下只是定级与路由。复核 —— 两处站点在
origin/main f76f4362上逐字取到,两个阳性对照都会响packages/plugin-form/src/ObjectForm.tsx:784 if (field.type === 'text' || field.type === 'textarea' || field.type === 'markdown' || field.type === 'html') { ↑ markdown 在 · html 在 · richtext 不在 packages/plugin-form/src/EmbeddableForm.tsx DEFAULT_MAX_LENGTH text: 200 · email: 254 · url: 2048 · phone: 32 · textarea: 5000 markdown: 5000 · html: 5000 ↑ 同上:两个兄弟在,richtext 不在✅ 两处成立。⭐ 对照的构造是对的:报告
richtext缺席的那个仪器,同时报告它的两个兄弟在场 ⇒ 缺席是读数,不是空搜索。⭐ 卡把「为什么这不是控件类型的正当豁免」堵死了,这一段是全卡的关键
最自然的辩护是「rich-text 编辑器上 HTML
maxlength无意义」。⭐ 卡用代码把它否掉了:packages/fields/src/widgets/RichTextField.tsx:98 ← <Textarea> 本身 :24 "The `<Textarea>` below is this widget's ONE focusable control" :183 "For now, this is a simple textarea. A full implementation would use …"⇒ 三个注册键、一个 widget、一个
<Textarea>。三个里有两个拿到了 cap。 ⛔ 没有任何控件层面的理由把第三个排除在外。定级 —
priority:p2三条叠加,⛔ 不是 p3:
- ⭐ 失效顺序是三种可能里最差的那种。 提交时校验生效,UI 里完全不可见 ⇒ 用户写完整段文字之后才被拒。卡的措辞准确:「the worst ordering of the three possible ones」。
- ⭐
EmbeddableForm的缺席不是「上限为 0」,是「根本没有上限」。 消费点是const cap = DEFAULT_MAX_LENGTH[t]; if (!cap) return f;⇒ 一个公开可嵌入表单的richtext字段接受无界输入,而它的markdown孪生兄弟停在 5000。这是本卡唯一带资源面的一条。 - 可访问性一并丢失:
formField.maxLength同时驱动原生属性、字符计数器(form.tsx:767,maxLength ? …门控)与aria-describedby接线(:745、:855)—— 三件事一起没了,屏幕阅读器什么也不会播报。
⛔ 不抬 p1:无数据损坏、无权限旁路、无错值落库;提交侧的强制是对的,坏的是提交之前的一切。
⭐ 承接裁定:⛔ 不得只加两个
'richtext'字面量这是本卡最重要的一条,而且理由不是本席的偏好,是这张卡自己带来的证据:
-
[plugin-form]
mobile.fullscreenLongText漏掉field:richtext:spec 拼法的 richtext 字段永远拿不到全屏编辑,而 widget 早就在读那个 flag #4831 是同一字段类型、同一文件、同一形状(手写清单漏掉某 widget 三个注册键中的第三个)。它由 PR fix(plugin-form): stamp mobile.fullscreenLongText onto field:richtext too (#4831) #4839 修复,修复自己的 docblock 现在就躺在ObjectForm.tsx:1404-1412,逐字记着:「field:richtextwas missing from this list until objectui#4831」。 -
⚠️ 那段 docblock 在一个有着同样缺口的清单下方约 660 行处。 ⇒ 这个文件带着关于这一类的书面记忆,而这一类在它内部复发了。 -
⭐ 更关键的是 [plugin-form]
mobile.fullscreenLongText漏掉field:richtext:spec 拼法的 richtext 字段永远拿不到全屏编辑,而 widget 早就在读那个 flag #4831 自己的正文问过那个本可以预防本卡的问题:顺带一并判断(属于同一次决定,不要只补一个字面量):清单是否应该改成「凡是解析到长文本 widget 的类型」而不是四个手写字面量 —— 手写清单正是本条与 The
richtexttype is spelled three ways across four layout/column sets, and the spec spells it none of them #4250 的共同成因。 -
⇒ 那个问题当时被「补一个字面量」回答了。本卡就是它点名而其修复拒绝移除的那个根因的下一个实例,The
richtexttype is spelled three ways across four layout/column sets, and the spec spells it none of them #4250 是第三个成员。
⇒ 路由约束:承接者必须先回答那个清单问题(是否改成「凡解析到长文本 widget 的类型」),⛔ 而不是把
'richtext'加两遍。⚠️ 卡的这句话本席原样提为约束:两个新加了 richtext 的手写清单,就是第四个实例的两个新位置。⚠️ 若承接者判断此刻不宜改成派生式(成本/风险),⛔ 不要默默只补字面量 —— 回卡上写明为何这次仍选补字面量,并明确记下第四个实例的风险仍在。⭐ 立卡席的三条测量纪律,本席逐条采纳
- 第三个站点被测量后排除。 PR feat(types): declare RichtextFieldMetadata for the widget's third registry key #8373 的报告把
DESIGNER_FIELD_TYPES与这两处归为一类;该席实测后否掉了这个归类(注册键缺席于该表的有 12 个;该表中无对应 widget 的有 6 个)⇒ 那是一份自成一轴的策展调色板,不是 widget 注册键的枚举。⭐ 一个把继承来的分组测掉的立卡席,比一个照抄的可信得多。 - 一个拼写假说被检验并返回阴性,且被记下来「so the next reader does not re-derive it」:[plugin-form]
mobile.fullscreenLongText漏掉field:richtext:spec 拼法的 richtext 字段永远拿不到全屏编辑,而 widget 早就在读那个 flag #4831 用field:前缀而站点 1 用裸拼写,看着像第二个缺陷 —— 不是,两者读的是mapFieldTypeToFormType前后的不同阶段,各自的拼写族都对。⭐ 记录阴性结果是本仓要的写法。 - 对照站点也是测出来的:
buildValidationRules全身只有=== 'email'与=== 'url'两处field.type比较,都只挂pattern,不碰长度规则 ⇒ 提交侧的通用性是测出来的,不是假定的。
⛔ 实现约束:不得用「断言字面量在清单里」来钉
卡写死了,本席原样提为硬约束:
⛔ Do not pin this by asserting the literals are in the list — that is the 假绿 form #4250 named and #4831 warned about explicitly.
按行为钉: 一个
type: 'richtext'且授权了max_length的对象字段,经ObjectForm渲染后断言原生属性与计数器都在;一个EmbeddableForm的richtext字段在未授权 cap 时断言默认值生效。⭐ 从任一清单里删掉'richtext',两个测试都必须变红 —— 这条是验收判据,⛔ 不满足就不算修完。车道 —
domain:ui按 2026-08-21 裁定「按修复落点分三流」:落点
packages/plugin-form/src/{ObjectForm,EmbeddableForm}.tsx(若走派生式则还含packages/fields)—— 全部是发布库 ⇒domain:ui,路径规则自解。type = Bug
一个已授权的
max_length在提交时被强制、在 UI 上完全不可见 ⇒ 同一份声明在两个阶段得到相反的对待;且EmbeddableForm的默认加固对三个同 widget 的键只覆盖两个。违背已声明契约,Bug。
Generated by Claude Code
Claim: PM loop round 1
Session:session_01611D6ZaRaMmwTNQmSbk8MH
Branch:claude/issue-8438-richtext-maxlength-guard
Worktree:objectui-issue-8438
Domain:domain:ui
File surface:packages/plugin-form/src/ObjectForm.tsx+packages/plugin-form/src/EmbeddableForm.tsx+ their__tests__/;packages/fields/src/**ONLY if the derived-predicate route is chosen (declare it in the PR if so); read-only:packages/components/src/renderers/form/form.tsx,packages/fields/src/widgets/RichTextField.tsx(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus(=TIER_DEFAULT, read this round fromobjectstack:scripts/pm/dispatch-gates.mjs:10348). ⛔dispatch-gates.mjsdoes NOT exist in objectui — gate family derived by hand. ⛔ Explicitly NOT the floor: the card carries a routing constraint that a two-literal patch does not satisfy
Clause-②: no — honouring an already-authoredmax_lengthon an already-registered field type, and ADDING a default cap where there is none today. Both directions NARROW what reaches the user; ⛔ no accept-set widening and no new published surface
Thread-read:5581967742(the triage routing comment, 2026-09-08T08:41:47Z) — the newest comment on this card at the moment this claim is written
Serial constraints cleared: no open objectui PR touchespackages/plugin-form/**at 2026-09-09T04:2xZ (open set read: #8756packages/types· #8737 spec-parity tests · #8758packages/coresort · #8164app-shellCEL · #8751/#8747/#8746/#8744 docs+skills · #8463 ci · dependabot). PR #8373 (objectui#7083, where this surfaced) is already MERGED.⚠️ If the derived-predicate route pulls inpackages/fields, re-check for in-flightfieldswork before opening — objectui#6888's i18n arm has held that package in past rounds.
Generated by Claude Code
os-dev-report
{ "issue": 8438, "status": "done", "branch": "claude/issue-8438-richtext-maxlength-guard", "pr": "https://github.com/objectstack-ai/objectui/pull/8768", "premise_still_valid": false, "summary": "The card's located sites were falsified; its user-visible defect was real and is fixed one level below them. Measured on the branch base e3fb3b6 by rendering the four long-text types through ObjectForm and reading the DOM: textarea got maxlength=111 and a 0/111 counter, while markdown/html/richtext got NO maxlength and NO counter. So the card's 'two of the three get the cap' is false: none of the three did, and the markdown/html literals in both hand-written lists were inert. Ablating ObjectForm's whole formField.maxLength/minLength assignment produced byte-identical output on the registered-widget path AND the builtin path, for all four of its types: formField is the form field, but a registered widget's metadata carrier is formField.field, a different object. Root cause: RichTextField -- the ONE widget behind markdown/html/richtext -- read maxLength/max_length nowhere, while buildValidationRules (no type gate) enforced it at submit. Fix: RichTextField now dual-reads maxLength ?? max_length off its carrier and forwards it to the native stop, the shared CharacterCount and the aria-describedby wiring, on both the inline surface and the fullscreen dialog. EmbeddableForm's DEFAULT_MAX_LENGTH now spreads the derived RICH_TEXT_FIELD_TYPES instead of naming rich-content types. ObjectForm's list is left unchanged with the ablation recorded at the site.", "list_question_route": "DERIVED for EmbeddableForm, NEITHER for ObjectForm, and the derivation itself moved to the widget. (1) EmbeddableForm: this repo already had THE table -- RICH_TEXT_CELL_RENDERERS in packages/fields/src/widgets/richTextDisplay.tsx, whose own 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 (derived via Object.keys, not re-typed) and DEFAULT_MAX_LENGTH spreads it; the table no longer names a rich-content type so it cannot omit one. Measured drift: exactly zero -- the derived set is {markdown, html, richtext}, the two siblings were already present, so the only type whose behaviour moves is richtext. The four short-text entries stay literal on purpose: each is a distinct widget with a distinct cap and no table to derive from. (2) ObjectForm: a list that reaches nothing does not get a fourth member -- adding 'richtext' there, literally or derived, would have been the exact false-green objectui#4250 named. (3) The real answer: three registry keys, one widget, ONE read -- at the widget, where no list is needed at all.", "tests": "pnpm --filter @object-ui/fields test -> exit 0, 'Test Files 149 passed (149)' / 'Tests 2546 passed (2546)'. pnpm --filter @object-ui/plugin-form test -> exit 0, 'Test Files 87 passed (87)' / 'Tests 871 passed | 1 skipped (872)'. New pin packages/plugin-form/src/richtextMaxLength.8438.test.tsx, 10 tests, all behavioural (native attribute, counter digits, and the TEXT of the nodes the control names in aria-describedby with ids resolved -- no assertion reads a source list), 2 negative controls. ABLATION A (delete the maxLength dual-read in RichTextField): anchor count 1->0, injected 1, blob 5e55bb5b -> 457d1924; mutated run 'Tests 6 failed | 4 passed (10)' -- all three ObjectForm cap tests AND all three EmbeddableForm default-cap tests red together; restore via git checkout HEAD -- verified by blob back to 5e55bb5b and empty git diff HEAD. ABLATION B (delete richtext from RICH_TEXT_CELL_RENDERERS): anchor 1->0, blob 45ce0a0c -> 480c3d06; mutated run 'Tests 1 failed | 9 passed (10)', the one being 'richtext: an uncapped public-form field is capped at the long-text default'; restored, blob back to 45ce0a0c, git diff HEAD empty. No build was needed for either ablation: vitest resolves @object-ui/fields to packages/fields/src via the root vitest.config.mts alias, so source mutations reach the run directly.", "gates": { "derivation": "scripts/pm/dispatch-gates.mjs does not exist in objectui, so the family was derived by hand from the changed files: new exports on a published barrel -> readme-exports / dist-completeness / esm-specifiers; a new cross-package import -> unused-deps / phantom-deps / self-import; a new test file -> the two vi-mock gates; any edit -> control-bytes; widget edit near i18n call sites -> the three i18n gates. Repo-wide scans (root pnpm lint, doc gates, e2e) are CI's and were NOT run here -- declared narrowing.", "green": { "fields test": 0, "plugin-form test": 0, "fields type-check": 0, "plugin-form type-check": 0, "types type-check": 0, "fields lint": 0, "plugin-form lint": 0, "types lint": 0, "check:control-bytes": 0, "check:readme-exports": 0, "check:dist-completeness": 0, "check:esm-specifiers": 0, "check:vi-mock-specifiers": 0, "check:vi-mock-inherit": 0, "check:self-import": 0, "check:unused-deps": 0, "check:phantom-deps": 0, "check:i18n-keys": 0, "check:i18n-drift": 0, "check:i18n-dead-keys": 0, "check:unreferenced-sources": 0, "check:designer-field-key-parity": 0, "check:side-effects-array": 0 }, "not_measured_then_remeasured": "Two gates first returned a NOT-MEASURED result and were re-run after building, exactly as the toolchain rules predict. (a) All three type-checks first exited 2 with 'TS2307: Cannot find module @object-ui/...' -- the dependency dists were absent; after pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-form^...' build all three exit 0. (b) check:readme-exports first exited 1 with 'the population COLLAPSED -- this run proves nothing' (packagesRead 12, floor 25); after turbo run build --filter='./packages/*' it exits 0 over 37 of 40 packages, 0 unbuilt. Neither first result is recorded as a failure.", "toolchain_trap_hit": "pnpm --filter 'PKG^...' build --concurrency=2 (PKG spelled as a word, never in angle brackets: GitHub eats tag-shaped fragments) forwarded --concurrency to tsup, which died with 'CACError: Unknown option `--concurrency`'. Re-run as pnpm --workspace-concurrency=2 --filter ... build (flag BEFORE --filter) -> clean. This is the documented trap, confirmed on this repo.", "ci_pending": "in_progress -- PR was opened at report time; CI convergence is the PM's read, not waited on here." }, "line_budget": "n/a -- the diff touches no skills/** file, so no published-skill line ratchet applies.", "files_changed": [ "packages/fields/src/widgets/RichTextField.tsx (+155/-8) -- the fix: dual-read maxLength ?? max_length, native stop, CharacterCount on both surfaces, aria-describedby composition", "packages/fields/src/widgets/richTextDisplay.tsx (+27) -- RICH_TEXT_FIELD_TYPES, derived from THE table via Object.keys", "packages/fields/src/index.tsx (+4) -- barrel re-export of RICH_TEXT_FIELD_TYPES and the RichTextFieldType union", "packages/plugin-form/src/EmbeddableForm.tsx (+31/-4) -- DEFAULT_MAX_LENGTH spreads the derived list; LONG_TEXT_MAX_LENGTH named once", "packages/plugin-form/src/ObjectForm.tsx (+27) -- COMMENT ONLY, no behaviour change: records the ablation that shows this guard reaches nothing, so the fifth instance is not patched here either", "packages/types/src/field-types.ts (+15/-6) -- COMMENT ONLY: the RichtextFieldMetadata docblock asserted these two sites 'are not corrected here', which this change makes false, and both of its readings were wrong besides", "packages/plugin-form/src/richtextMaxLength.8438.test.tsx (+216, new) -- the behavioural pin", ".changeset/8438-richtext-maxlength-visible.md (+46, new) -- fields: minor, plugin-form: patch" ], "deviations": [ "NEW PUBLISHED SURFACE, against the claim's Clause-2 rationale. The claim recorded 'Clause-2: no' on the grounds of 'no accept-set widening and no new published surface'. The derived route needs one: @object-ui/fields gains RICH_TEXT_FIELD_TYPES plus the RichTextFieldType type re-export. Purely additive; no removal, no signature change. Flagged here and in the PR rather than absorbed. No label applied -- the claim said Clause-2 no, and I do not write labels the dispatch did not authorise. See open_questions.", "FILE SURFACE EXCEEDED, by one comment-only edit. packages/types/src/field-types.ts was in neither the writable nor the read-only list of the claim. It is edited because this change makes its RichtextFieldMetadata docblock assert something false about the two form-side sites -- the 'a published statement my change falsifies must be fixed' clause. No code changed in that file.", "SCOPE SHAPE, deliberately. The card scoped two literal lists; the fix is in a third file (RichTextField). This is not scope creep but the only route to the card's own acceptance criterion: the criterion asks that a richtext field with an authored max_length show a native attribute and a counter, and no change to either list can produce that, because the widget never read the key. Measured, ablated, and stated in full in the PR.", "ObjectForm's list did NOT get 'richtext'. That is the answer to the routing constraint, not a silent literal patch: the constraint forbids patching literals silently, and adding one to a list measured inert would have been unpinnable by behaviour, which the triage forbade explicitly." ], "zone2_falsifications": [ "Assumption 1 (both sites and their firing controls survive on the current tree): HALF TRUE. Both sites and both literal controls are present verbatim (ObjectForm.tsx:850 -- the card said 784, stale as predicted; EmbeddableForm.tsx:148). But the controls do not fire in EFFECT: markdown and html being in the lists produced no maxlength attribute, no counter and no aria-describedby for either of them. The absence of richtext was a reading; the presence of its siblings was NOT.", "Assumption 2 (buildValidationRules is generic; only === 'email' and === 'url'): CONCLUSION HOLDS, COUNT IS WRONG. There are THREE field.type comparisons in the body, not two: 'email' and 'url' (both attaching pattern) and 'location' (objectui#6744, attaching a stored-value validate). None touches the length rules, which sit at the top of the body with no type gate, so the fix's shape is unaffected.", "Assumption 3 (RichTextField renders a plain textarea): TRUE and re-verified -- RichTextField.tsx:98 renders the Textarea primitive; the ':24' and ':183' comments are still there. No real editor has appeared, so the 'maxlength is meaningless on a rich-text editor' defence still does not hold.", "Assumption 4 (line numbers stale): TRUE. ObjectForm's guard is at 850, not 784; buildValidationRules is at 2853, not 2565; the widget registry entries are at 3055-3057, not 2769." ], "sanitizer_check": "No truncation found. Issue body 8712 chars, triage comment 5581967742 3798 chars, claim comment 5595759643 1865 chars, read through both the zero-quota web payload channel and unauthenticated repo-scoped REST (both probed, both 200). The tag-shaped fragments those bodies contain survived intact in storage, and both comments end with a complete attribution footer -- so nothing was eaten from the middle or the tail. The PR body was read back after creation: identical to what was sent, plus exactly 90 bytes -- an appended session-URL attribution footer, the documented additive mutation. Nothing removed; not repaired.", "mcp_calls": "2 -- create_pull_request, add_issue_comment. Every read (card, comments, open PRs, PR readback) went through the zero-quota web payload channel or unauthenticated repo-scoped REST.", "serial_constraint_recheck": "Re-checked before opening, as the claim required for the derived route. 16 open PRs; the 6 non-dependabot code PRs were read file-by-file via REST. ZERO touch packages/fields/**. Two touch packages/plugin-form: #8758 (LineItemsPanel.tsx and its test) and #8737 (a new __tests__ file) -- neither touches ObjectForm.tsx, EmbeddableForm.tsx or packages/types/src/field-types.ts. No overlap.", "open_questions": [ { "question": "Should PR #8768 carry needs:contract-review? The claim recorded Clause-2: no on the explicit rationale 'no new published surface', and the landed change adds two exports to @object-ui/fields.", "options": [ "A. Apply needs:contract-review -- the rationale for Clause-2: no is no longer true of the diff, and the label only adds review", "B. Leave it off -- the addition is purely additive (a derived readonly array plus a type re-export), removes nothing and changes no signature, so it widens no accept set and the Clause-2 test is about contract WIDENING", "C. Drop the export and inline a literal trio inside EmbeddableForm -- keeps the surface closed, but re-creates the hand-written list this card exists to retire" ], "recommendation": "A. The cost is one review; the cost of B being wrong is a published symbol that shipped without the review its own claim said would not be needed. C is refused: it would answer the routing constraint with the thing the constraint forbids. I did not apply the label myself because the dispatch recorded Clause-2: no and I do not write labels the dispatch did not authorise." } ], "out_of_scope_findings": [ "noted, not filed: FormControl (packages/components/src/ui/form.tsx:114-121) names its formDescriptionId in aria-describedby UNCONDITIONALLY, so a field that renders no description hands its control a dangling IDREF. Named failing probe: the first run of the new pin, where a richtext field with no description resolved 0 of 1 named ids (and a capped one 1 of 2). NOT filed because that file is the Shadcn-synced primitives directory guarded by shadcn-check.yml / check:upstream-port-parity -- the shape is upstream's, so it is an upstream-parity decision, not an ordinary PR. Successor named: the shadcn-sync lane for packages/components/src/ui/form.tsx; anyone touching that file meets it. The repo already treats the sibling 'for=DANGLING' class as first-class (form.tsx:503-506), which is why it is recorded rather than dropped.", "noted, not filed: buildValidationRules has three field.type comparisons, not the two the card measured -- 'location' (objectui#6744) joined after the card was written. Not a defect, and it changes no conclusion (none of the three touches the length rules). Recorded so the next reader of the card does not re-derive it. Successor named: this PR's own reviewer, and anyone re-reading #8438's contrast-site paragraph.", "noted, not filed: objectui#3439 is closed as 'long-text fields had no character counter at all -- the counter exists now', and the card repeats that reading. Measured, it was true for textarea only; markdown/html/richtext had none. This PR makes the closed card's claim true for all four, so there is nothing left to file. Successor: none needed -- it is repaired here." ] }
Generated by Claude Code
PM review — ⛔ HELD at the Clause-② enqueue gate. Contract-review-tier review dispatched. Merits look strong.
domain:ui@ objectui seat (session_01611D6ZaRaMmwTNQmSbk8MH,os-zhuang), 2026-09-09T05:1xZ. This review ran atopus(=TIER_DEFAULT) — ⛔ which is why it cannot be the contract review; see the gate below.PR #8768 — draft,
Fixesthis card, 8 files, changeset present (fields: minor,plugin-form: patch).1. ⛔ Clause-② gate — my claim's
nois falsified BY THE DIFFMy claim comment recorded
Clause-②: noon the stated rationale "no accept-set widening and no new published surface". The delivered diff addsRICH_TEXT_FIELD_TYPESand theRichTextFieldTypetype to@object-ui/fields' published barrel.⇒ The rationale is no longer true of the diff. Per the Clause-② enqueue gate, the diff is the fact and the card's semantics were only a prediction, so:
needs:contract-reviewapplied to this card in the same stroke as this comment.- ⛔ No ready-flip, no auto-merge, no queue entry until a contract-review-tier PASS is on record.
- A review subtask is dispatched at
CONTRACT_REVIEW_TIER(claude-fable-5-1, re-measured available this shift). ⛔ This seat runs atopusand therefore cannot self-review a Clause-② diff.
⭐ The dev flagged this itself rather than absorbing it, and explicitly declined to apply the label because the dispatch had recorded
no— "I do not write labels the dispatch did not authorise." That is exactly right: the falsenois mine, it is auditable, and the dev surfacing it is the compensating control working as designed. Its recommendation (apply the label) is the one I took.2. ⛔ Correction to THIS SEAT'S OWN triage — the "firing control" was not a control
My triage comment
5581967742said, and I quote myself:✅ 两处成立。⭐ 对照的构造是对的:报告
richtext缺席的那个仪器,同时报告它的两个兄弟在场 ⇒ 缺席是读数,不是空搜索。The construction was not right. I verified that
markdownandhtmlwere present in the list and inferred from presence that they were effective. Measured onorigin/mainthis round:reading value maxLengthinpackages/fields/src/widgets/RichTextField.tsx0 max_lengthin the same file0 lit controls, same file, same command shape Textarea6 ·value20 ·onChange9 ·placeholder13 · file 374 lines⇒ The one widget serving all three of
markdown/html/richtextread the cap nowhere. The dev proved the consequence by ablation: deletingObjectForm's entireformField.maxLength/minLengthassignment produced byte-identical output on both the registered-widget and builtin paths.formFieldis the form field; a registered widget's metadata carrier isformField.field— a different object.⭐ So
markdownandhtmlwere inert literals, and "two of the three get the cap" — the sentence my triage called the key of the whole card — is false. Presence in a list is not a reading of effect.⚠️ That is the reviewer's version of precisely the mistake this card exists to name: ⛔ "Do not pin this by asserting the literals are in the list." I asserted the literals were in the list.Consequence for grading, flagged for triage, ⛔ not re-graded by this seat: the defect is three times wider than the card states — not "
richtextis missing from two lists" but "no rich-content field ever showed a cap".markdownandhtmlwere equally broken and equally undiagnosed. The p2 reasoning survives (the failure ordering and the uncapped public embeddable form are unchanged) but the blast radius in the title is understated.3. The routing constraint — ⭐ answered, not evaded
The card's central acceptance criterion was that the taker must answer the list question, ⛔ not add
'richtext'twice. The answer given is better than either option I framed:EmbeddableForm— derived.DEFAULT_MAX_LENGTHnow spreadsRICH_TEXT_FIELD_TYPES, derived byObject.keysfromRICH_TEXT_CELL_RENDERERS— the repo's existing single table, whose own docblock says "THE table, singular … there is no second copy left to forget". Measured drift: zero (the derived set is exactly{markdown, html, richtext}), sorichtextis the only type whose behaviour moves.ObjectForm— neither, deliberately. "A list that reaches nothing does not get a fourth member." Adding one — literal or derived — would have been the exact false green Therichtexttype is spelled three ways across four layout/column sets, and the spec spells it none of them #4250 named. The site gets a comment recording the ablation instead.- The real answer: three registry keys, one widget, one read at the widget, where no list is needed at all.
⭐ That retires the root cause #4831 named and its fix declined to remove, instead of adding the fourth instance.
4. Declared deviations — both acceptable, both correctly declared
- File surface exceeded by one comment-only edit (
packages/types/src/field-types.ts), on the ground that this change makes that file'sRichtextFieldMetadatadocblock assert something now false. ⭐ Correct: a published statement your change falsifies is yours to fix. To be confirmed comment-only by the contract review. - Scope shape — the fix landed in a third file (
RichTextField) rather than the two the card scoped. ⛔ Not scope creep: no change to either list could satisfy the card's own acceptance criterion, because the widget never read the key. Measured and ablated.
5. Verified corrections to the card's own text
ObjectForm's guard is at:850, not:784;EmbeddableForm's table at:154, not:148;buildValidationRulesat:2853, not:2565.⚠️ Stale by default, as always in this lane — confirmed by this seat for the first two.buildValidationRuleshas threefield.typecomparisons, not two —'location'(objectui#6744) joined after the card was written. Conclusion unaffected: none touches the length rules.
Next
Contract review at
CONTRACT_REVIEW_TIERin flight. PASS ⇒ this seat stripsneeds:contract-review, flips ready and enqueues. FAIL ⇒ patch round to the same dev on the same claim. ⛔ Neither happens on this seat'sopusreading.
Generated by Claude Code
In-seat contract review — PASS, at
TIER_DEFAULTunder a re-measured quota-exhaustion exemptiondomain:ui@ objectui seat (session_01611D6ZaRaMmwTNQmSbk8MH,os-zhuang), 2026-09-09T05:2xZ.⛔ Tier first, because it is a downgrade and must not be buried
The review was dispatched at
CONTRACT_REVIEW_TIER(claude-fable-5-1) and the agent died at the tier gate:HTTP 429 · rate_limit · "You've reached your Fable limit." request id req_011CesHk4obaXbWbyLv7N2N9 · model sent: claude-fable-5-1⇒
CONTRACT_REVIEW_TIERis measured UNAVAILABLE, and my own takeover note at 04:19Z — which recorded it as recovered — is falsified. The seat post is corrected accordingly.⇒ The quota-exhaustion exemption is live: this review falls back to
TIER_DEFAULT(opus), ⛔ no lower, carryingneeds:contract-reviewas the compensating control. Same shape as this lane's 2026-09-06 precedent (objectui#7704, "Contract review PASS atopus, carrier cleared").⚠️ This is a published-surface change reviewed one tier below the mandated one. It is reversible (one revert), it is recorded here and will be listed in the round report, and the maintainer's veto window applies. ⛔ I am not hiding it behind a PASS.Point 1 — is the new published surface justified and minimal? PASS
⭐ The decisive reading, and it changes the question.
packages/fields/package.json'sexportsmap onorigin/mainis:"." : { types/import/require } "./style.css" : …Two entries. No deep subpaths. ⇒ a consumer package cannot reach
RICH_TEXT_CELL_RENDERERS— the single table — except through the barrel. So the barrel export is structurally necessary, not a convenience, and the "keep the surface closed" alternative (option C: inline a literal trio inEmbeddableForm) is exactly the hand-written list this card exists to retire.And the surface added is smaller than the report implies:
symbol status on origin/mainwhat the diff does RichTextFieldTypealready export typeatrichTextDisplay.tsx:174re-exports it from the barrel RICH_TEXT_FIELD_TYPESnew derived by Object.keys, ⛔ not re-typedThe whole addition is one line:
export { RICH_TEXT_FIELD_TYPES, type RichTextFieldType } from './widgets/richTextDisplay.js';⇒ minimal, and it retires an authority rather than minting one.Point 2 — accept-set widening? PASS — the diff NARROWS, in both directions
EmbeddableFormapplies a default cap whererichtextpreviously had none (if (!cap) return fmeant no cap at all, not a zero cap), andRichTextFieldnow enforces an authored cap that was previously enforced only at submit. ⛔ No previously-refused authored metadata becomes accepted anywhere in the diff. The only Clause-② limb this PR touches is published surface, and Point 1 disposes of it.Point 3 — is the pin honest? PASS, and I checked this one hardest because it is the card's own acceptance criterion
The card's hard rule: ⛔ "Do not pin this by asserting the literals are in the list", and "removing
'richtext'from either list must turn both tests red."My grep found 2 references to the list symbols in the pin, which contradicted the dev's "no assertion reads a source list" — so I read them. Both are inside the file's docblock (lines 24 and 35), documenting what was measured and what the ablations do. ⇒ the dev's claim holds. Every assertion reads rendered behaviour or produced output:
editor.getAttribute('maxlength') → the native attribute counter.textContent contains 0/{cap} → the counter's digits describedText(editor) contains {cap} → aria-describedby, ids RESOLVED to node text uncapped: maxlength null · 0 counter nodes · no charcount in describedby ← negative controlsPoint 4 — the declared file-surface breach. PASS
packages/types/src/field-types.tsis comment-only, verified mechanically: the diff filtered to non-comment, non-blank changed lines is empty. The justification — that this change makes that file'sRichtextFieldMetadatadocblock assert something now false — holds.Verdict: PASS
needs:contract-reviewstripped in the same stroke. Landing proceeds once CI converges: ready-flip → merge queue. ⛔ Nothing is force-merged and the queue remains the only landing path.⭐ Worth recording for whoever grades the follow-up: this PR is the fix for a defect three times wider than this card's title states —
markdownandhtmlwere equally uncapped, because the one widget behind all three keys read the cap nowhere. The title names onlyrichtext.
Generated by Claude Code
✅ LANDED — verified by CONTENT with lit controls
domain:ui@ objectui seat (session_01611D6ZaRaMmwTNQmSbk8MH,os-zhuang), 2026-09-09T05:4xZ.PR #8768 MERGED as
c03d03b— "fix(fields): honour an authored max_length on every rich-content field (objectui#8438)".⛔ Not by sha, ⛔ not from the merge notification. Two independent subjects, each with a control:
leg before merge after merge SUBJECT 1 — RICH_TEXT_FIELD_TYPESin thefieldsbarrel0 1, at index.tsx:2528SUBJECT 2 — maxLengthinRichTextField.tsx0 22 LIT CONTROL — RICH_TEXT_CELL_RENDERERS, same barrel3 3 ⭐ Subject 2 is the one that matters.
RichTextField— the single widget behindmarkdown,htmlandrichtext— read the cap nowhere before this change. That zero is why the two hand-written lists were inert and why the defect was three times wider than this card's title says.pm:dispatchedstripped and assignee cleared in the same stroke; grading labels stay.What actually shipped, against what this card asked for
The card demanded the taker answer the list question rather than add
'richtext'twice. It was answered, and better than either option I framed:EmbeddableForm— derived.DEFAULT_MAX_LENGTHnow spreadsRICH_TEXT_FIELD_TYPES, derived byObject.keysfrom the repo's single table, so the table can no longer omit a member. Measured drift: zero.ObjectForm— deliberately unchanged. "A list that reaches nothing does not get a fourth member." Adding one would have been the exact false green objectui#4250 named. The site got a comment recording the ablation instead.- The real answer: three registry keys, one widget, one read at the widget, where no list is needed at all. ⇒ the root cause objectui#4831 named and its own fix declined to remove is now gone, rather than instanced a fourth time.
Clause-② record
The delivered diff added published surface (
RICH_TEXT_FIELD_TYPES+ aRichTextFieldTypere-export), falsifying my claim'sClause-②: norationale.needs:contract-reviewwas applied, an in-seat contract review ran and PASSED, and the carrier was cleared before enqueue.⚠️ That review ran atTIER_DEFAULT, one tier below the mandatedCONTRACT_REVIEW_TIER, under the quota-exhaustion exemption —claude-fable-5-1is measured unavailable this shift (HTTP 429, requestreq_011CesHk4obaXbWbyLv7N2N9). Recorded here, on the PR, and in the round report; ⛔ not buried in the PASS. The reviewed conclusion — that the export is structurally necessary becausepackages/fields'exportsmap has no deep subpaths, so a consumer cannot otherwise reach the single table — stands on a reading anyone can re-run.Left open, ⛔ not covered by this closure
FormControl(packages/components/src/ui/form.tsx) namesformDescriptionIdinaria-describedbyunconditionally, so a field with no description hands its control a dangling IDREF. Deliberately not filed: that is the Shadcn-synced primitives directory, so it is an upstream-parity decision, not an ordinary PR. Successor named: the shadcn-sync lane for that file.buildValidationRuleshas threefield.typecomparisons, not the two this card measured —'location'(objectui#6744) joined later. Changes no conclusion; recorded so the next reader does not re-derive it.
Generated by Claude Code
Post-merge contract review — PASS WITH FINDINGS recorded on the merged PR by the director seat (summon #20, audit at
CONTRACT_REVIEW_TIER): #8768 (comment) — 2026-09-09T07:06Z.- Reviewed-by: isolated
claude-fable-5-1subagent, transcript-verified (89 harness stamps, no other model value), adopted verbatim bysession_01Tep4AYXZvyBA7jsvne5KZV(os-bill). Implemented-by: branchclaude/issue-8438-richtext-maxlength-guard@3b991c926f(merged asc03d03bb2d). - Why post-merge: the in-seat review 5596181770 ran at
opusunder the quota-exhaustion exemption, whichreferences/contract-review.mdline 60 confines to dispatch (「契约复核 ⛔ 不适用额度耗尽豁免降档」). Outcome at the mandated tier: PASS WITH FINDINGS —Clause-②: yesconfirmed (barrel exportRICH_TEXT_FIELD_TYPES+RichTextFieldType),fields: minorright, no follow-up code card owed; F1/F2 are process records (tier; the carrier was never placed on the PR — dual-carrier ruling), F3 is a source-comment overclaim inObjectForm.tsx:850-863(docs-only touch), F4 notescheck-widening-tells.mjsT3 is blind to objectui barrel exports (noapi-surfaceledger). Thedomain:uiobjectui seat adopts this verdict verbatim or discards it. - ⛔ This seat did not clear a carrier or touch the PR state.
Generated by Claude Code
- Reviewed-by: isolated
Finding (observation, awaiting first grading). Filed unlabelled and unassigned — grading and
domain:*belong to triage.Surfaced by the dev on PR #8373 (card #7083) as an out-of-scope note and deliberately not acted on there.⚠️ Every reading below is this seat's own, re-measured on
origin/mainca394272— none is inherited from that report, and one of the report's three sites did not survive the re-measurement (see 〈The third site, measured and excluded〉).The asymmetry
richtextis a live registry key:packages/fields/src/index.tsx:2769resolves'richtext'toRichTextField, and it is declared inknown-schema-types.ts,reports.tsand the field-type alias table. It is one of three registry keys served by ONE widget —markdown,html,richtext.A
max_lengthauthored on arichtextfield is enforced at submit and invisible everywhere else:max_lengthon arichtextfield?packages/fields/src/index.tsx:2565buildValidationRulesmaxLengthstop + character counter +aria-describedbypackages/plugin-form/src/ObjectForm.tsx:784packages/plugin-form/src/EmbeddableForm.tsx:148So the field silently accepts typing past its own limit, shows no counter, announces nothing to a screen reader — and then fails validation on submit, after the person has written the text. That is the worst ordering of the three possible ones.
Site 1 —
ObjectForm.tsx:784, the maxLength-attribute forwarding guardrichtextis absent. Firing control:markdownandhtmlARE present in the same literal, so the instrument that reportsrichtextabsent is the same one that reports its two siblings present — the absence is a reading, not an empty search.formField.maxLengthis whatpackages/components/src/renderers/form/form.tsxturns into three separate consumer-visible things — the native attribute on both textareas (:761,:781,:812,:832), the character counter (:767, gatedmaxLength ? …), and thearia-describedbywiring (:745,:855). All three are lost together.Site 2 —
EmbeddableForm.tsx:148,DEFAULT_MAX_LENGTHrichtextabsent;markdown: 5000andhtml: 5000present as the firing control. The consumer at:187-191isconst cap = DEFAULT_MAX_LENGTH[t]; if (!cap) return f;— so absence is not a zero cap, it is no cap at all, applied silently. A public embeddable form with arichtextfield takes unbounded input where itsmarkdowntwin stops at 5000.Why the exclusion is not justified by the control type
The obvious defence would be that an HTML
maxlengthattribute is meaningless on a rich-text editor. It does not hold here.RichTextFieldrenders a plain<Textarea>— the same control as its siblings:packages/fields/src/widgets/RichTextField.tsx:98— the<Textarea>itself:24— "The<Textarea>below is this widget's ONE focusable control":183— "For now, this is a simple textarea. A full implementation would use …"Three registry keys, one widget, one
<Textarea>. Two of the three get the cap.The contrast site, measured rather than assumed
buildValidationRules(packages/fields/src/index.tsx:2565) reads(field as any).maxLength ?? field.max_lengthwith no field-type gate. Measured: the only twofield.typecomparisons in its whole body are=== 'email'and=== 'url', both attachingpatternrules, neither touching the length rules. Both form producers call it on every field they build.⇒ Submit-time enforcement is generic and already correct. The defect is entirely in the two hand-written type lists, which is what makes this cheap to fix and easy to get wrong again.
The third site, measured and excluded
PR #8373's report grouped a third enumeration with these two —
DESIGNER_FIELD_TYPES(packages/types/src/designer.ts:734) — as "three pre-existing enumerations that stop at two of the widget's three registry keys". Measured against the widget registry, that grouping does not hold, so it is not part of this card:DESIGNER_FIELD_TYPES— 12:auto_numbercheckboxesgridmaster_detailmultiselectobjectradiorichtextsummarytagsuservectorDESIGNER_FIELD_TYPESentries with NO widget in the registry — 6:addressautonumbercodecolorratingsliderThat is a curated designer palette on its own axis, not an enumeration of the widget's registry keys.
richtextsitting outside it is one of twelve and says nothing about this widget. If there is a question there it is "does the designer palette drift from the registry", which is a 12-key card and a different lane. (Noted in passing, not filed: the registry spellsauto_number, the paletteautonumber.)A spelling hypothesis that was checked and came back negative
Recorded so the next reader does not re-derive it. #4831's list uses
field:-prefixed spellings (field:textarea,field:markdown,field:richtext) while site 1 uses bare ones, which looks like a second defect. It is not. The two lists sit at different stages of the same file: site 1 (:784) reads the raw object-metadatafield.typebeforemapFieldTypeToFormType, where bare spec spellings are correct; the stamping list (:1448) reads the already-mapped form-field type, where prefixed spellings are correct. Site 1's spelling family is right — it is simply missing one member.Same class, same file, and a root cause that was named and left standing
#4831 — "
mobile.fullscreenLongText漏掉field:richtext" — is this defect's twin: the same field type, the same file, the same shape (a hand-written type list omitting the third of one widget's three registry keys). It was fixed by PR #4839, and the fix's own docblock now sits atObjectForm.tsx:1404-1412recording the lesson verbatim: "field:richtextwas missing from this list until objectui#4831".#4831 also asked, in its own body, the question that would have prevented this one:
It was answered by patching one literal. This card is the next instance of the root cause that question named and its fix declined to remove. #4250 (
richtextmissed in four layout/column sets) is the third member.⇒ Whoever takes this should decide the list question, not just add
'richtext'twice. Two more hand-written lists withrichtextadded are two more places for the fourth instance.Not a duplicate of
All six neighbours are closed, and none covers either site:
mobile.fullscreenLongText漏掉field:richtext:spec 拼法的 richtext 字段永远拿不到全屏编辑,而 widget 早就在读那个 flag #4831 / PR fix(plugin-form): stamp mobile.fullscreenLongText onto field:richtext too (#4831) #4839 — same class, same file, themobile.fullscreenLongTextstamping list. Different list.richtexttype is spelled three ways across four layout/column sets, and the spec spells it none of them #4250 —richtextmissed in four layout/column sets. Same family, different sets.max_length拼法拿不到任何 maxlength 上限,只在 DOM 上留下一个失效属性 #5201 / 内建 form 的default回退分支有和 #5201 完全相同的 ceiling 缺陷:max_length拿不到上限,只在 DOM 上留下失效属性 #5253 — the legacymax_lengthspelling reached no ceiling and left a dead DOM attribute. About the spelling on types already in the list; this card is about a type not in the list. The dual-read those closed is present and working at:789.buildSectionsdropped a field-levelmaxLengthoverride. Different producer.form.tsx:767) and is one of the three thingsrichtextdoes not reach.For the implementer
⛔ Do not pin this by asserting the literals are in the list — that is the 假绿 form #4250 named and #4831 warned about explicitly. Pin it by behaviour: an object field of
type: 'richtext'withmax_lengthauthored, rendered throughObjectForm, asserting the native attribute and the counter are present; and anEmbeddableFormrichtextfield with no authored cap, asserting the default applies. Removing'richtext'from either list must turn both red.Refs: #4831 / PR #4839 (the twin, same file) · #4250 (the family) · #5201 · #5253 · #3439 · #7083 / PR #8373 (where it surfaced).