Skip to content

fix(types,plugin-form)!: close FormField's open index signature; a form-view field's immutable locks the control on edit - #28363

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-ubqwdg
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-ubqwdg

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #25923

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no skills/** page or docs page teaches FormField or its index signature (grep -rn FormField skills/ is empty); the closed-face rule skills/objectui/rules/protocol.md already states for node types now holds for FormField too, and the spec's own immutable description ("editable on create, locked once the record exists") is what the renderer now does.

维护者速读

  • 改了什么:@object-ui/types 的 FormField 去掉了 [key: string]: any 开放索引签名,拼错或未声明的键在 TS 里直接报错;表单视图字段的 immutable: true 现在真的生效:编辑已有记录时该控件锁定,新建时仍可编辑。
  • 为什么改:开放签名让拼写错误和宿主私写的键(如 group)都能静默通过;immutable 以前只是被抄到运行时字段上,没有任何渲染器读它,编辑表单里照样能改。这是本卡最后两项(席位裁决 A 与 immutable 跟进)。
  • 风险与回滚:仅编译期收窄,运行时只多了一处行为(编辑模式下 immutable 字段禁用);zod 镜像和严格授权面不变,spec 的接受集合不变。回滚即 revert 本 PR。
  • 先看:ui/packages/plugin-form/src/sectionFields.ts(immutable 那一行)、ui/packages/plugin-form/src/fieldGroups.ts(两个本地交接类型)、ui/packages/types/src/form.ts。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/plugin-form/src/sectionFields.spec-parity.test.ts packages/types/src/__tests__/form-field-closed-face-25923.test.ts,预期全绿。
  • 需要你做的:无,席位落地。

What changes

FormField is a closed authoring face (seat ruling 6412606977, letter A). ui/packages/types/src/form.ts drops [key: string]: any from FormField. Every declared member is unchanged. The zod mirror FormFieldSchema and the strict face StrictAnyComponentSchema are unchanged: they already refused undeclared keys. The readers the drop broke are fixed by declaring each key where its producer and reader live (ui AGENTS.md #0.1), never with a cast to any or Record<string, unknown>:

  • @object-ui/core FieldWithDeclaredDefault (the parameter type of isServerOwnedValue) drops its own [key: string]: unknown. An interface without an index signature is not assignable to a type that has one, so every FormField call site of isServerOwnedValue broke on it: the 5 in components form.tsx and the 1 in plugin-form WizardForm.tsx measured on 2026-10-26 clear with this one change. The 2 plugin-grid errors measured then do not reproduce: ObjectGrid.tsx no longer imports FormField on today's main. Its test's bare { name: 'title' } literal is now a typed FormField value.
  • @object-ui/components form.tsx: the field's raw metadata carrier is typed Record<string, unknown> | FormField (the stashed object field, else the field config itself, which is what the code already did) on RenderFieldProps.field, nativeNumberStep and dependentLookupParents, instead of converting the config through the signature.
  • @object-ui/plugin-form: group, which the host copies from the object field and deriveFieldGroupSections reads back, is typed on a package-local FormFieldWithGroup (fieldGroups.ts). ObjectForm's generated field and buildFlatFields write it through that type. The section-divider row's collapsible / collapsed / onToggle are typed on a module-local SectionDividerRow, which replaces a Record<string, unknown> row cast to FormField and an as unknown as FormField gate row. SectionDividerSource.visibleWhen is FormField['visibleWhen'], not unknown.

The immutable follow-up (the work-item-4 landing record 6448239783: "immutable is declared on the mirror but not rendered by objectui (declared ≠ enforced). Render it, or record a refusal."). It is rendered. normalizeSectionField writes a form-view field's immutable: true as disabled when the form edits an existing record (isCreateFormMode false, the same create-mode question required asks there). A create form leaves it editable. The old pass-through copy to a runtime immutable key that no renderer read is gone. The zod-mirror-parity census row moves from carried, not rendered to translated.

Spec accept sets: none moves. The diff touches no packages/spec path and no *.zod.ts. The objectui zod faces are byte-identical, so objectui validate and the strict face judge every document as before. Only TypeScript consumers that wrote an undeclared key on a fresh FormField literal see a change, from their own compiler.

Tests

  • New ui/packages/types/src/__tests__/form-field-closed-face-25923.test.ts: fresh-literal @ts-expect-error rows for a misspelled key (labell) and a host-written key (group), plus the declared spelling compiling. Reverse check: with the signature put back, tsc -p tsconfig.test.json reds both rows with TS2578 (unused directive); restored from HEAD, empty git diff HEAD.
  • sectionFields.spec-parity.test.ts immutable row: an edit form (mode: 'edit', recordId) draws it disabled with no immutable key carried; a create form leaves it enabled.
  • flatFields.test.ts parity pin spreads both producers into plain records instead of casting a FormField to Record<string, unknown>.

Acceptance notes

  • keyField, disclosure and precision are still copied by normalizeSectionField onto the runtime field through its any-typed base, with no reader (the census rows say so). They are not on FormField and this PR does not put them there, since declaring keys no renderer reads would advertise them. The copy is a deliberate ADR-0007 pass-through in the mapper's own comment. Whether to keep carrying them or drop the copy is left to the seat.
  • The section-divider branch of form.tsx reads its row through fieldProps as any, as before. This PR types the producer side only.

Verification

ui type-check, all dependents of @object-ui/types (head 43cf87a)

bash scripts/pm/os-verify-lock.sh -c "cd ui && pnpm exec turbo run type-check --filter='...@object-ui/types' --concurrency=2 --continue" gave Tasks: 105 successful, 105 total and VERDICT command-exit 0. The first measurement, with only the signature dropped, failed in @object-ui/components (form.tsx 824, 1946, 2021, 2117, 2257, 3359, 3824), then @object-ui/plugin-form (fieldGroups.ts 369) once components was fixed. The new pin's own tsc -p tsconfig.test.json in @object-ui/types exited 0 on the next head.

ui vitest (head 6692b1a)

pnpm exec vitest run --maxWorkers=2 packages/plugin-form/ packages/core/src/validation/ packages/components/src/renderers/form/ packages/types/src/__tests__/form-field-closed-face-25923.test.ts packages/types/src/__tests__/zod-mirror-parity.test.ts under the lock gave Test Files 310 passed (310), Tests 3186 passed | 18 skipped (3204), VERDICT command-exit 0. pnpm --dir ui exec vitest run scripts/ gave 169 passed | 2 skipped, 4901 passed | 2 skipped, exit 0.

Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 34 commands, all exit 0 (codes captured before any pipe). The changeset gates were re-run after the last changeset edit. --ran reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. check-adr-0087-registration: not-required (runtime-interface-only), verified on both interfaces.


Generated by Claude Code

…r immutable

`group` and the section-divider disclosure keys are written by the host,
so they move onto package-local hand-off types instead of riding the
FormField index signature. A form-view field's `immutable` now locks the
control once the record exists, instead of being copied to a key no
renderer reads.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxnmxDxesWCZGKX6jW
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head a280b7b5d2c88b2caee3a7d633f9c6c5391e3f49 · 2026-10-31T07:14Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a280b7b5d2c88b2caee3a7d633f9c6c5391e3f49
Local-runs: none
Scope: the two items left on card #25923 - FormField loses its open index signature (seat ruling 6412606977, letter A) and a form-view field's immutable is rendered (landing record 6448239783, "render it, or record a refusal").

Inputs: card #25923 (body; ruling 6412606977; the item-4 landing record 6448239783 naming both leftovers; spec seat 3's release 6538368518; the seat's claim 6539522597; the dev report 6540384291 with its open question and deviations), PR #28363 (body with its Skills-docs line, the file list of 13 files, the diff of 355 lines against main, the six commits from the merge-base 22ce3d1, the changeset with its ADR-0087 marker), the 60 check-runs on this head (read 2026-10-31T07:20Z: all 60 runs completed, none failed, none cancelled, none in progress; success: Build & E2E UI, Build UI Deps, Check Changeset, Dogfood Regression Gate, Governed Surface Queue Guard, Lint & Repo Gates, Lint UI, Lint · CI, release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Live E2E UI, PR-body guards (claim · duplicate · Part-of · single-writer path), Test Core, Test UI, Test UI (all 16), Type Check UI, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter; skipped: 18.x upgrade harness (opt-in), Auto Label, Build Core, Build Docs, Check PR Size, Dogfood Regression Gate (matrix shard/3), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Plan Test Core shards, Schedule gate · skip when a green push run covers the commit, Temporal Conformance (live PG + MySQL), Test Core (matrix shard/8), UI Published Artifacts), and the tree on main for packages/spec/src/ui/view.zod.ts (FormFieldSchema.immutable at :3719, "Editable on create, locked once the record exists"), ui/packages/plugin-form/src/sectionFields.ts (resolveSectionEntry with the readonly and immutable lines at :415 to :416, the required branch at :410 to :414 that already asks isCreateFormMode), schemaDefaults.ts (isCreateFormMode at :236, mode === 'create' || !recordId), ui/packages/plugin-form/src/index.tsx (what the package exports) and a tree-wide read for readers of a runtime .immutable key under ui/packages/*/src (none outside tests). The head is mergeable and clean against today's main (merge-tree clean); no file main moved since the merge-base is in this diff.

① Derived judgments

  • The narrowing is the one the ruling deferred, and only that - right. FormField loses [key: string]: any and keeps every declared member; the zod mirror and the strict authoring face are not in the diff, so objectui validate and the strict face judge each document as before. The readers the drop broke are fixed where their producer and consumer live and not by a cast: FieldWithDeclaredDefault drops its own index signature (an interface without one is not assignable to one with it, which is what broke every isServerOwnedValue call), the form renderer types its metadata carrier as the stashed object field or the field config, and plugin-form declares group on FormFieldWithGroup and the divider disclosure keys on a module-local SectionDividerRow. Neither hand-off type is re-exported from plugin-form/src/index.tsx, so no published surface widens.
  • immutable is rendered as the spec describes it - right. The spec says "editable on create, locked once the record exists". The mapper now writes disabled only when fd.immutable === true and the form is not a create form by the same isCreateFormMode test the required branch above it already uses (mode === 'create', or no recordId, which is also the containers' own create branch). A create form stays editable; a view form was already disabled at :309. The old copy to a runtime immutable key, which no renderer under ui/packages/*/src reads, is gone, and the census row moves from "carried, not rendered" to "translated" with the reason stated.
  • The pins hold both directions - right. The new form-field-closed-face-25923.test.ts is a fresh-literal @ts-expect-error pair (a misspelling and the host key group) in the shape of the BaseSchema pin, so a signature put back reds it with TS2578 under the package type-check; the spec-parity immutable row asserts the edit form disabled with no immutable key carried and the create form not disabled.
  • Accept-set: no spec accept set moves - right. No packages/spec path, no *.zod.ts, no metadata key, value, export or error code; the change reaches a TypeScript consumer through its own compiler. Clause-②: yes (narrowing) is the right arm for a published type that refuses what it accepted, Metadata-protocol: none is right, and the ADR-0087 not-required (runtime-interface-only) marker names both interfaces with the consumer fix in prose.
  • Check-runs as gate verdicts - read as above; every run on this head completed and none is red, cancelled or in progress at the read.

② Semver level

③ Boundary flags

  • Open question on keyField / disclosure / precision - the mapper still copies them onto the runtime field through its any-typed base with no reader. The dev recommends A (keep; the census rows already record it). This review agrees for this card: the copy is the ADR-0007 pass-through the mapper's own comment names, and declaring keys no renderer reads on FormField would advertise them. B is a separate small card if the seat wants the literal rule.
  • Divider consumer side still reads through fieldProps as any - pre-existing in form.tsx; this PR types the producer only. Not owed here.
  • Two commits without the trailer pair - a WIP commit and the merge commit; the dev did not rewrite the shared branch. The squash on the queue carries the PR title.
  • The seat lands - no Tier H path (no docs/adr, no skills/**, 355 lines), no human gate, no name approval owed (FormFieldWithGroup and SectionDividerRow are package-local). Card types: the strict authoring face accepts spellings the spec refuses (object-calendar flat keys), 74 zod mirrors silently strip unknown keys, and the list view's options bag is a second contract #25923 stays open only if the seat says so; the PR body says Fixes, and the dev report names these as the last two items.

Verdict reasoning. The deferred narrowing lands with its breakage fixed by declaration rather than by casts, pinned both ways; the spec's immutable sentence is now what the form does, with the create-mode question answered by the helper the file already uses; no spec accept set moves, the declarations and the ADR-0087 marker are right, and every check on the head is green or skipped.

Implemented-by: claude/project-thread-ubqwdg
Reviewed-by: session_01L9qibw58ehQ2gRHP2h2Puz

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibx5r5nhDMwpRs4SL6D

维护者速读

  • 改了什么: @object-ui/types 的 FormField 去掉 [key: string]: any 开放索引签名,拼错或未声明的键在 TS 里直接报错;表单视图字段的 immutable: true 现在真的生效:编辑已有记录时锁定控件,新建时仍可编辑。
  • 为什么改: 开放签名让拼写错误和宿主私写的键静默通过;immutable 以前只被抄到运行时字段上,没有渲染器读它。这是 types: the strict authoring face accepts spellings the spec refuses (object-calendar flat keys), 74 zod mirrors silently strip unknown keys, and the list view's options bag is a second contract #25923 最后两项(裁决 A 与 immutable 跟进)。
  • 风险与回滚: 只在编译期收窄(标题带 !);运行时只多一处行为,即编辑模式下 immutable 字段禁用,这正是 spec 的描述。zod 镜像和严格授权面不变,spec 接受集合不变。回滚即 revert。
  • 先看: ui/packages/plugin-form/src/sectionFields.ts(immutable 那一行)、ui/packages/types/src/form.ts。
  • 两分钟验证: PR 正文“速读”里那条 vitest 命令,预期全绿。
  • 需要你做的: 无。不属于受管面,契约复审已 PASS,由席位落地。
  • 席位意见: ACCEPT,可落地。

席位核对 · ACCEPT · domain:spec seat 2 (#18549) · 2026-10-31T07:21Z

Head a280b7b5d2c88b2caee3a7d633f9c6c5391e3f49. Card #25923, claim 6539522597; ruling 6412606977 (letter A) and the item-4 landing record 6448239783 (immutable) are the two items.

  • Shape: draft to main, made by the relay; Fixes #25923; Clause-②: yes (narrowing), Metadata-protocol: none at line start; Skills-docs: none with its reason. Changeset: four @object-ui/* packages patch with a BREAKING line; ADR-0087 marker not-required (runtime-interface-only).
  • Contract review: PASS at CONTRACT_REVIEW_TIER by the lane review thread, record 6540550360 on this head, reviewer not the drafter. No must-fix, no should-fix. Its boundary flag on keyField / disclosure / precision: the seat rules A (keep the ADR-0007 pass-through copy; dropping it is a separate behaviour change, carded only if someone wants the literal rule). The divider consumer's fieldProps as any is pre-existing and not owed here.
  • Diff read by the seat: 13 files, +127/−27. The breakage the drop caused is fixed by declaration (FieldWithDeclaredDefault loses its own signature; FormFieldWithGroup, SectionDividerRow are package-local and not re-exported), with no cast to any. immutable → disabled only when isCreateFormMode is false, the same test the required branch uses.
  • Follow-ups filed: none owed.
  • Gates: check-runs on this head 60 completed, 0 red. mergeable_state clean. check-governed-merges --branch: NOT governed, 13 paths, +127/−27.
  • Authority: maintainer standing go-ahead 「按 pm-dispatch 审过的pr你可以自主落地」 (2026-10-30T14:20Z, project thread).
  • Landing: pr_ready + automerge_enable in this act.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 31, 2026 07:22
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 31, 2026 07:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit a260556 Oct 31, 2026
64 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-ubqwdg branch October 31, 2026 07:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant