Skip to content

Commit 1289925

Browse files
feat(spec)!: object-form customFields takes a closed runtime form field, and both forms' sections a page-block section shape (#21464, S-forms) (#21742)
Part of #21464 Clause-②: yes (narrowing) ## What this does The S-forms stage of the `ComponentPropsMap` `z.unknown()` close-out. It executes the maintainer's rulings on forks 2 and 3 of the decision card #21704 (ruling record `5978663135`, batch #276, letters B and B), per the claim `5979114945`. Read points are at the `.objectui-sha` pin `2e818d0b51ec`, under objectui `packages/` unless named. Every cited reader file is byte-identical at objectui `main` `fd060f076`, and the pin is an ancestor of that `main`. | row · member | was | now | |:--|:--|:--| | `object-form` · `customFields` | `z.unknown()` | a list of the closed runtime form field (module-private, built once): camelCase, keyed by `name`, only the members the form draws; its `options` entry is the closed runtime option the form's option controls draw | | `object-form` · `sections` | `z.array(z.unknown())` | a page-block section shape of its own (module-private, built once): the form view's section keys plus the three entry arms the form reads, canonical spellings only | | `object-master-detail-form` · `sections` | `z.array(z.unknown())` | the same section instance (its parent half hands `sections` to the form verbatim, `plugin-form/src/MasterDetailForm.tsx:1692`) | The stored form view's `FormSectionSchema` (`view.zod.ts`) is not edited. Forks 1, 4 and 5 keep their enumeration-pin lines. #21704 is not addressed beyond forks 2 and 3. ## The fix round (head `716f4c6522`) The at-tier contract review of `b473439752` (record `5980612890`) answered **FAIL** on one point, and the seat's order `5980628111` took its remedy 1. This round, by the session `session_016tKoy8NJa35Yih1FdzrVmn` (the director seat's dispatch), changes: 1. **`options` on the runtime form field is re-typed.** It took the form view's option (`FormSelectOptionSchema`), whose `value` is a stored field's lowercase identifier, so the shipped `object-manager` dialog's options (`{ label: 'Box', value: 'Box' }`, objectui `plugin-designer/src/ObjectManager.tsx:244`-`:245`) were refused. It is now a closed option of the keys the form's option readers draw, measured at the pin (below): `label`, `value` (`string | number | boolean`, as objectui's runtime option declares on purpose, `types/src/zod/form.zod.ts:142`-`:145`), `description` and `visibleWhen`. Every other ruled member is unchanged. 2. **The census was re-run and every quote of it corrected** (this body, the changeset, both D3 entries). The first run read a `.map` over a constant list as non-static and so never parsed the object manager's options; the instrument now evaluates it. 3. **The Dogfood Regression Gate's red is fixed.** `expression-conformance.test.ts` reported four UNCLASSIFIED surfaces (`buildObjectFormRuntimeField.visibleWhen` / `.readonlyWhen` / `.requiredWhen`, `buildObjectFormSection.visibleWhen`). They are classified in `expression-conformance.ledger.ts` as row `cel-form-block-field-rule`, and the new option's `visibleWhen` as row `cel-form-block-option-visible`, in the shape of the existing `ui/component.zod.ts` page-block rows (#20420 is the precedent for a spec-lane edit of that ledger). 4. **Acceptance notes** carry the review's ③ notes 4 and 5, with carrier objectstack-ai/objectui#11615. The widget-only keys question is answered A by the record: the field stays closed at the measured set. ## Fork 2 B — the runtime form field **How a member reaches a draw.** `customFieldsMerge.ts:78-108`, called from `ObjectForm.tsx:1178-1185` and from every other `formType` arm, merges `customFields` over the generated fields. A member naming a generated field replaces its whole definition, and any other member is appended. A section's inline entry is drawn as it stands (`sectionFields.ts:369-370`). Either way the renderer hands the field to its widget as the metadata carrier (`components/src/renderers/form/form.tsx:3171`, `field.field || field`). **The draw set, measured from the readers, member by member.** These are not transcribed from objectui's `FormField`. | members | read at | |:--|:--| | `name`, `label`, `description`, `type`, `required`, `disabled`, `readonly`, `hidden`, `validation`, `visibleWhen`, `readonlyWhen`, `requiredWhen`, `colSpan` | `form.tsx` `renderFormField` destructure `:2675-2693`; `hidden` `:2696`; the three rules `:2732`; `validation` `:2795`; `colSpan` `:2988` | | `widget`, `multiple` | `:2915-2918` (`widget` ahead of `type`, arity from `multiple`) | | `options`, `dependsOn` | `:2934-2944` (the cascading option list) | | `placeholder`, `inputType` | `:3185`, `:3174` (the built-in input's `type`) | | `span`, `colSpan` | `plugin-form/src/autoLayout.ts:162-172` | | `group` | `plugin-form/src/fieldGroups.ts:52` (the object's field-group sections are derived over the drawn fields) | | `rows` · `accept`, `multiple` · `dimensions` · `reference` · `min`, `max` | `fields/src/widgets/TextAreaField.tsx:102` · `FileField.tsx:147-148` · `VectorField.tsx:11` · `LookupField.tsx:326` · `NumberField.tsx:88-89` | | `minLength`, `maxLength`, `pattern` | the built-in input and textarea branches (`form.tsx:4080`, `:4146`; `pattern` rides onto the native control) | | `returnType` · `summaryOperations` · `columns` | `FormulaField.tsx:22` · `SummaryField.tsx:15` · `GridField.tsx:588-589` | **The option, measured from the option readers.** Every option control the form reaches reads the same four keys: | key | read at | |:--|:--| | `label`, `value` | the built-in select (`form.tsx:3975-3977`, the pick mapped back to the authored value by `matchOptionValue`, `:3955`) and the four option widgets `SelectField`, `MultiSelectField`, `RadioField`, `CheckboxesField`, which draw `optionDisplayLabel` (`core/src/evaluator/optionRules.ts:173`) and stringify `value` only at the control | | `visibleWhen` | the cascade: `resolveCascadingOptions` → `resolveVisibleOptions` (`optionRules.ts:97-110`), from `form.tsx:2940` and from each widget's `useCascadingOptions` | | `description` | a lookup field's static options: its typeahead searches the description beside the label (`fields/src/widgets/LookupField.tsx:705-706`) | `color` (drawn only by the list and grid select cell renderer, never by a form option control), `default`, and objectui's `disabled` (read only by the standalone `select` node renderer, `components/src/renderers/form/select.tsx:91`, which a form field never reaches) and `icon` (read by no option control on the form path) are refused, each with a prescription. Aliases name `label` (`text`, `name`, `title`), `value` (`key`, `id`) and `visibleWhen` (`visible`, `showWhen`). **Value types.** Where this package already declares a member, its value schema is taken by reference: the object field's `FieldSchema` members for `rows`, `accept`, `dimensions`, `reference`, `minLength`, `maxLength`, `returnType`, `summaryOperations` and `columns` (`inlineColumns`); `EvaluatedExpressionInputSchema` for the three `*When` rules; and the object field's `dependsOn` list beside a bare name. The option's `label` and `description` are the object field's option's own (`SelectOptionSchema.shape`, pinned def by def). Its `visibleWhen` is the evaluated predicate declared on the option itself, because the object field's option is also re-checked by the server on write and an inline option never is. `group` takes the field-group key grammar (`SectionGroupKeySchema`). `label`, `description` and `placeholder` are plain strings, because the renderer draws each as it stands and an inline locale map would be a React child. `validation` is `{ required?, minLength?, maxLength?, min?, max? }`. Each bound rule is `{ value, message }`. `required` is a string, because the renderer deletes the rule and reads only its message (`form.tsx:2843`, `:2847`). **Read, and refused anyway, each with a prescription:** - the `grid` widget's eight snake_case keys (`min_rows`, `max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, `add_label`, `sort_field`), by the ruling. Their carrier is objectstack-ai/objectui#11610. - `visibleOn` (`form.tsx:2767`) and the legacy `condition` (`:2717`), two more spellings of the conditional-visibility predicate. ADR-0089 D1 makes `visibleWhen` the single canonical key. The only measured `visibleOn` writer is a type-level test that never draws (below). - `id`: the renderer keys the row by `id ?? name` (`:2898`), and `name` is already unique in the drawn list. - `fields`: the member claim of the section-divider row the form builds from a section, not a member of a field. **The census's two keys outside objectui's 45 members:** - `group` is read (above), so it is typed. - `defaultValue` is not read, so it is refused. The form opens on `initialValues` and on the object's declared defaults (`schemaDefaults.ts`), and objectui's `initialRecordMerge-9760.test.tsx` row 7 pins that an inline `defaultValue` seeds nothing. The prescription moves the value into the block's `initialValues`. ## Fork 3 B — the page-block section shape - **Section keys, read:** - `name` and `label`: the heading (`ObjectForm.tsx:1693` and the per-`formType` maps at `:412`, `:492`, `:520`, `:556`, `:590`). - `description`. - `collapsible` / `collapsed` (`resolveSectionCollapse`, `:1702`). - `visibleWhen` (the divider row's predicate, `:1720`). - `columns` (`:1731`). - `pane` (`SplitForm.tsx:445`). - `group` (`sectionGroups.ts`). - `fields`. That is exactly `FormSectionSchema`'s key set, and objectui's `ObjectFormSection` declares the same (`types/src/objectql.ts:1497`). The form view's group-reference rule rides with it (`sectionGroupReferenceRefinement`, the same derived-key lists). - **Canonical spellings only.** A page block's `properties` is never parsed on the way to the form, so the form view's two folds do not run there, and the form reads only `visibleWhen` off a section and only a numeric `columns` (`clampCol`, `:1599`). A section `visibleOn` and a string `columns: '2'` were therefore dropped in silence. Both are refused with the canonical spelling. `label` is a plain string, because the form draws the heading as it stands. No member carries a schema default. - **The three entry arms:** - **A field name.** - **The form view's `{ field }` entry.** Its members ARE `FormFieldSchema`'s object half, pinned def by def, with three differences that follow from how the page block reaches the form: - its deprecated `visibleOn` is refused, as above (the form reads `visibleWhen ?? visibleOn`, `sectionFields.ts:455`); - `label`, `placeholder` and `helpText` are plain strings (copied onto the drawn field as they stand, `:386-388`); - `span` drops the default the form view fills. Its sub-fields are this same entry, recursively, so the canonical rule holds at every depth. - **The inline runtime form field.** This is fork 2's instance, by identity (pinned). - **Both rows share one section instance** (pinned by identity). A bare CEL predicate parses to its `{ dialect, source }` envelope, as on every evaluated slot. So `object-form`'s input and parsed types now differ, and the row leaves the type-alias pin's isomorphic family for an `ObjectFormPropsParsed` alias (ADR-0122), as `object-master-detail-form` did on #20928. That alias is the one new export. ## The census (re-run in the fix round) **The instrument** is a TypeScript-AST walk over `.ts` `.tsx` `.js` `.jsx` `.mjs` `.cjs` `.json` and fenced code in `.md` / `.mdx`. It finds: - **Pass 1:** object literals naming either block by `type` (flat or in `properties`), literals annotated or asserted as `ObjectFormSchema` / `MasterDetailFormSchema`, the block's React component's `schema` prop, and direct parses through the row. A member that is a parameter of the enclosing helper is resolved to the argument in that position at every same-file call site. - **Pass 2:** every object literal carrying `customFields` or `sections` in a file that names a form block. Values resolve through same-file constants and spreads, and, new in this round, through a `.map` over a constant list (an arrow with identifier parameters and an expression body). The first run read such a list as non-static, so the object manager's option lists were never parsed: that miss is the review's ① 7. Every static value was parsed through this head's rows, union arms judged by their best arm. Every value with a non-static part, and every refusal, was read by hand. **Positive control.** The same extracted values, parsed through `b473439752`'s `component.zod.ts`, refuse `ObjectManager.tsx:239` members 4 and 5 (`icon`, `group`) at every `options.N.value` (`invalid_format`, the identifier rule). Through this head's rows they parse. | corpus | `customFields` | `sections` (`object-form` · `object-master-detail-form`) | |:--|:--|:--| | objectstack `316be321ef` (the previous merge base; the three commits `main` gained since add no writer) | 0 | 3 · 0. All parse: `examples/app-showcase/src/ui/pages/new-project-wizard.page.ts`, `packages/lint/src/validate-component-props.test.ts`, `packages/spec/src/ui/component.test.ts`. Field names only. | | objectui pin `2e818d0b51ec` | 31 parse, 2 refused (probes), 11 non-static | 102 with a static part parse, 2 refused (probes) · 4 parse; 26 fully non-static, read by hand | | objectui `main` `fd060f076` | as the pin, plus 2 test values that parse | as the pin, plus 1 test value that parses | | hotcrm `4054ec2680` | 0 | 0 | | cloud `2205b53010` | 0 | 0 (one form view, which these rows do not judge) | **`customFields` at objectui:** - **The 31 that parse:** - the block literals: `guideCrudAppRenders.test.tsx:170`, `objectFormCustomFieldsMembers-8071.test.tsx:191` and `submitTargetRefusal.test.tsx:155`, `:317`; - the designer's `ObjectManager.tsx:239`, the registered `object-manager` component's modal form. Its labels are `t(...)` calls (non-static); its `icon` and `group` options are now evaluated from `ICON_OPTIONS` and `OBJECT_GROUPS` (`'Box'`, `'ShoppingCart'`, `'Custom Objects'`, …) and parse as runtime option values; - 26 helper and embeddable-form arguments, which `EmbeddableForm.tsx:567` hands to the form as `customFields`. Among them: the merge pins, the sections-and-members pins, the mobile fullscreen pin with `rows` / `placeholder` / `field:textarea`, the field-group row with `group`, and `content/docs/guide/public-forms.md`. - At objectui `main`, `apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx:78` and `:84` (a `{ name, label, type }` field each) parse as well. - **The 2 refused, re-read by hand, both probes and not drawn values:** - `types/src/__tests__/p1-spec-alignment.test.ts:348`: a type-annotated literal whose `visibleOn: '${data.industry != null}'` is never rendered and is not CEL. - `plugin-form/src/__tests__/initialRecordMerge-9760.test.tsx:236`: the `memo` member whose `defaultValue` the test's row 7 pins as seeding nothing. - **The 11 non-static:** run-time hand-offs (`EmbeddableForm.tsx:567`, `ObjectView.tsx:2599`, the arm forwards in `DrawerForm`, `ModalForm`, `ObjectForm`, `SplitForm`, `TabbedForm`, `WizardForm`) and helper parameters. No other `customFields` value has a non-static `options`. **`sections` at objectui:** - **Two block writers are refused, re-read by hand, both probes.** objectui's own renderer test (`plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx:270`, `:307`) states that this door refuses both shapes at parse: a section declaring neither `fields` nor `group` ("off-spec, so reachable only from a programmatic SDUI caller"), and a group-owned `label` / `collapsible` beside `group`, which the renderer reports and ignores. These are the form view's own group-reference rule. - **The parsing values include:** - the field designer's inline fields (`plugin-designer/src/FieldDesigner.tsx:344`: `name`, `label`, `type`, `required`, `placeholder`, `disabled`, `options`, `visibleWhen`). Its one non-static `options`, `flatTypeOptions`, was read by hand: `{ label: FIELD_TYPE_META[ft].label, value: ft }` per field type, two keys the option declares; - the plugin-form README's data-source-free wizard (inline fields with `inputType`); - the one `{ field }` entry (`cli/src/__tests__/spec-vocabulary-hint.test.ts:54`); - group, collapse, `columns`, `pane` and `visibleWhen` sections; - at objectui `main`, `objectname-neither-hint-11605.test.tsx:210` (an inline field in a section). - **The 26 fully non-static values** (23 on `object-form`, three on the master-detail form) are the form's own run-time hand-offs (`ObjectForm.tsx:386`, `MasterDetailForm.tsx:1692`, `DrawerForm.tsx:878`, `ModalForm.tsx:1062`, `ViewPreview.tsx:150`, `ObjectView.tsx:2603`) and test-helper parameters. Read by hand, they use declared keys only, with one exception: `sectionStyleKeysRetired-13626.test.tsx`, objectui's probe that a retired `className` / `gridClassName` reaches nothing (refused here, as objectui's own type refuses it). - **Pass 2's refused section values** are `record:details`, detail-view and object-view form-slot sections, which these rows do not judge. No measured working writer is refused under this head's shapes. ## Changes - **`packages/spec/src/ui/component.zod.ts`:** - the runtime form field, its `validation` block, its option (new in the fix round), the form view's `{ field }` entry arm and the section shape — five module-private factories, each built once (a factory and not a `lazySchema`, for the alias-integrity walk's reason, as `objectGanttMarker()`); - the two rows' members; - `ObjectFormPropsParsed`; - the imports (`FormFieldSchema`; `FieldSchema` and `SelectOptionSchema` from `../data/field.zod`). - **`packages/spec/src/ui/component-props-unknown-members.pin.test.ts`:** - the `customFields` and both `sections[]` fork lines leave; - the predicate ASTs inside the new shapes join the expression-AST lines (`customFields[]` / `sections[].fields[]` `visibleWhen` / `readonlyWhen` / `requiredWhen`, an option's `visibleWhen`, a grid column's two rules, and `sections[].visibleWhen`); - the roll-up `summaryOperations.filter{}` gets its own `shared` reason (`FILTER_CONDITION`: a query `where` over the child object's fields, judged by the filter schema's own refinement); - the `fork` stage's text drops the form field. - **`packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts` (new):** - §1: 19 byte-identical census and lit-control parses (the fix round adds runtime option values `'Box'`, `'ShoppingCart'`, `1`, `2`, `true`, `false`, and an option's `description` and `visibleWhen`), the shipped `object-manager` dialog's inline fields byte-identical, the predicate envelope (an option's bare `visibleWhen` included) and the absent case. - §2: 35 refusals by `code` and `path` (the fix round adds an undeclared option key, an option `color`, an option `default`, an option with no `label`, and an object `value`), plus the prescriptions, the option's included. - §3: the exact draw set, the option's exact key set (`description`, `label`, `value`, `visibleWhen`; not the form view's option; `label` and `description` def-identical to the object field's option; `value` accepts a string, a number and a boolean and refuses `null`, `undefined`, an object and an array), no snake_case key, the form view's section keys minus `visibleOn`, the `{ field }` arm's members def by def, the inline arm's identity and the shared section instance. - §4: the two D3 ids. - **`packages/qa/dogfood/test/expression-conformance.ledger.ts` (fix round):** rows `cel-form-block-field-rule` (the inline field's three rules and the section's `visibleWhen`: objectui `resolveFieldRuleState` → `evalFieldPredicate`, fail-soft-log) and `cel-form-block-option-visible` (the option's `visibleWhen`: `resolveCascadingOptions`, fail-soft-log, UI gating only). - **`packages/spec/src/type-alias-convention.pin.test.ts`:** the `ObjectFormPropsSchema` Iso pin leaves (773 → 772), with its receipt. This file is outside the claim's list. It reds otherwise, and the convention's route is to declare the alias and delete the pin. - **`packages/spec/dropped-refinements.baseline.json`:** five new sites. These are the refinements the new shapes carry by reference, which `z.toJSONSchema` drops: the section's group-reference rule, the inline grid column's rules, and the roll-up filter's comparand refinement. `droppedRefinementSites` goes 665 → 670. The fix round's option adds no site (the build's own check passes unchanged). This is hand-edited as the ledger requires, and no rule is weakened. - **The ADR-0087 kit:** - `18.ui-object-form-custom-fields-typed.ts` and `18.ui-object-form-sections-typed.ts` (new D3 entries; the fix round corrects both census quotes and the option text, and the sections comment names the `formSectionGroupReference-7051` probes as the refused values); - `registry.ts`: the semantic region is regenerated, and the step-18 rationale fragments sit at orders **78 and 79**. - There is no D2 conversion and no `RETIRED_KEYS_BY_MAJOR` row: page-component `properties` is not parsed on the save or load path, and the refused values are nested member values. - **Generated:** `content/docs/references/ui/component.mdx` (two member rows and three nested-shape tables; the `options` row now prints the runtime option), `docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md` (`ui/` 198 → 204 and `component.zod.ts` 68 → 74, the six new strict sites), `api-surface/ui.json` and `export-origins/ui.json` (`ObjectFormPropsParsed`). - **`.changeset/21464-component-props-form-custom-fields-sections-typed.md`:** `@objectstack/spec` `minor`, a BREAKING banner, the `Clause-②` line, the ADR-0087 `registered` marker naming both ids, FROM → TO (two option rows added) and the census, corrected. ## Measurements These are at head `716f4c6522` (merge base `ff29410ed2`: `origin/main` was merged in through `scripts/pm/os-regen-merge.sh` with no conflict, and after a rebuild every generated artifact checks up to date) unless named. `component.zod.ts` is blob `373d03336dd9` from `c04a77716c` to the head. Heavy runs went through `scripts/pm/os-verify-lock.sh`, each `VERDICT command-exit` read, and every exit code was captured before any pipe. - **Red first, the fix round.** The new pin cases were committed alone (`1b08dec21d`) and run against the unfixed rows: 9 failed, 61 passed (70). The runtime option values (`'Box'`, a number, a boolean) and the object-manager dialog were refused (the old option refused even `value: 'a'`, `too_small` under the identifier rule), and an option `color` was accepted. On the fix (`c04a77716c`): 70 passed, and an undeclared option key is still refused (`unrecognized_keys` at `customFields.0.options.0`). - **Red first, the stage** (at `b473439752`). On the published `@objectstack/spec@17.6.0` (the npm tarball, `ComponentPropsMap[row].safeParse`), all 16 junk values are ACCEPTED: `customFields: 42`, a member with no `name`, a misspelled member, `visibleOn`, `defaultValue`, `min_rows` and `validation.required: true`; on `object-form`, `sections: [42]`, a section `visibleOn`, `columns: '2'`, `className`, a section with neither `fields` nor `group`, a `{ field }` entry's `visibleOn` and an inline entry's unknown key; on `object-master-detail-form`, a section `visibleOn` and `[42]`. All 16 are refused with the code and path the pins assert. - **Tests:** - `pnpm --filter @objectstack/spec test`: Test Files 614 passed (614); Tests 18285 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` OK at 52 files / 246 errors / 135 signatures held. - `pnpm --filter @objectstack/lint test` (its closure from the full build below): Test Files 119 passed, Tests 5627 passed. - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/expression-conformance.test.ts`: Test Files 1 passed, Tests 7 passed. This is the test the Dogfood Regression Gate (3/3) failed on at `b473439752`. - **Public door.** `validateComponentProps` over the built lint and spec: a stack shaped like the object-manager dialog (`'Box'`, `'Custom Objects'`, `1` and `true` option values) reports 0 findings; an option `color` and an option `bogus` report `component-props-unknown-key` at `customFields.0.options.0.color` (with the prescription) and `customFields.0.options.1.bogus`. - **Ablation, the fix round** (at `e948519edd`; `component.zod.ts` blob `373d03336dd9`, the head's). The driver wraps `node scripts/ablation-replace.mjs` in its own `EXIT INT TERM` trap on the absolute path, with the HEAD blob as the restore target and an empty hash read as failure. The pins import `./component.zod` from source and the conformance test scans source, so no build or dist preflight is owed. | leg | mutation | blob after mutation | result | |:--|:--|:--|:--| | option element | `options: z.array(buildObjectFormRuntimeOption())` → `z.array(z.unknown())`, anchor x1 → x0 | `30c27aa10dd2` | red, 8 failed / 62 passed: the five option refusals, the option prescriptions, the option key set, and an option predicate's envelope | | discovery control | the ledger row `cel-form-block-option-visible` with its cover emptied | `1009587f14b8` | red, 1 failed / 6 passed: `UNCLASSIFIED surface — add a ledger row (ADR-0060): ui/component.zod.ts:buildObjectFormRuntimeOption.visibleWhen` | After each leg the blob equals HEAD and `git diff HEAD` is empty. The stage's three legs (at `3c4c7ce1a8`: `customFields`, `object-form` `sections` and `object-master-detail-form` `sections` each to `z.unknown()`, red 24, 21 and 5 failed) cover the members this round does not change. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 114 commands at `716f4c6522` (14 paths, +1375 / −75, under the 5000 threshold). All 114 ran at that head. On the first pass 112 exited 0, and `check:skill-examples` and `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: this fresh worktree had no package builds); both exited 0 after the full `turbo run build` (72 tasks, 71 of them from the shared turbo cache, exit 0). The `--ran` reconciliation reads: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. - **Named extras, with this body as the `pull_request` payload:** `check-changeset-no-major.mjs --base origin/main --event` exit 0 (declaration line `Clause-②: yes (narrowing)`, arm `narrowing`); `check-adr-0087-registration.mjs` exit 0 (`registered ui-object-form-custom-fields-typed, ui-object-form-sections-typed`); `check-empty-changeset.mjs` exit 0. - **Narrowed lint.** `eslint --no-inline-config --format json` over the eight changed TS files: 8 files, 0 errors, 0 warnings. - The population is read from eslint itself: each of the eight resolves a config, and the six other changed files (Markdown, MDX, JSON) answer "File ignored because no matching configuration was supplied" (`--print-config` prints `undefined`). - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, its own note at about line 328), so this diff moves no untouched file's verdict. - The full `pnpm lint` is CI's. - **Control bytes.** A self-scan of the 14 changed files found no hit, and `check:nul-bytes` (a derived gate) is green. - **NOT MEASURED:** - the Console Pin Gate and the full `pnpm lint`. Reason: CI-owned; objectui was read at the pin and at `main`, and not built against this spec. - CI on this head: not waited on. ## Acceptance notes (not filed) - **The React tier still publishes the older surfaces.** The React `ObjectForm` block's overlay types `customFields` as `any[]`, and its `sections` come from `FormViewSchema` (`packages/spec/src/ui/react-blocks.ts`, published as `skills/objectstack-ui/references/react-blocks.md`). The React tier is a separate, hand-declared contract for programmatic mounts, and a React prop is not authored metadata. Carrier: none. - **Two row-level rules are not carried.** The form view refuses `pane` off a split form and `group` / `visibleWhen` / a `true` collapse pair on a wizard step, in `FormViewSchema`'s own refinement. These are row-level rules coupling `formType` to the sections, not section-shape rules, so the ruled shape does not carry them. On a page block, objectui answers a wizard `group` with an empty step and a warning. Carrier: none. - **Three wider field sets are not declared:** - **Field-widget carrier reads** outside objectui's 45-member `FormField` vocabulary: `scale` and `step` (`NumberField`, `SliderField`), `language` (`CodeField`), `maxSize`, `capture` and `crop` (`FileField`, `ImageField`), and `LookupField`'s `displayField` / `idField` / `lookupColumns` / … No census writer uses one, and objectui's own strict face declares none. The record answered A: the field stays closed at the measured set and grows when a writer appears. - **The `type` / `widget` namespace rule** objectui's authoring face enforces (a colon-qualified id must name `field:`) is not carried; both are strings here. Carrier: none. - **A union refusal reads as a value verdict at the door.** A section entry refused by every arm, for example a `{ field }` entry's `visibleOn`, prints all three arms' refusals rather than the unknown-key rule id. Each arm's prescription is in the message, but the lint's single-arm routing needs exactly one arm with key-only issues. Carrier: none. - **The default `simple` arm skips an inline section entry its pool lacks** (the review's ③ note 4). On `formType: 'simple'` (the default), `ObjectForm` passes its field pool into section resolution (`plugin-form/src/ObjectForm.tsx:1617`-`:1627`), and `buildSectionFields` `continue`s on an entry the pool lacks (`sectionFields.ts:480`-`:484`). So a self-contained inline entry whose `name` neither the object nor `customFields` declares is skipped there, while the tabbed, wizard, split, drawer and modal arms resolve with no pool and draw it; objectui's own inline-sections test runs every arm except `simple` (`plugin-form/src/submitTargetRefusal.test.tsx:116`, `:331`-`:358`). The spec admits the inline arm on every `formType`, as fork 3 B rules; the gap is the renderer's and does not change the ruled shape. Carrier: objectstack-ai/objectui#11615. - **objectui's `ObjectFormSection.fields` TypeScript type has no `{ field }` arm** (the review's ③ note 5). It is `(string | FormField)[]`, and `FormField` requires `name`, so objectui's own TS face has no arm for the form view's `{ field }` entry that `sectionFields.ts:373-455` draws (its zod mirror takes `z.any()` there). Carrier: the same card, objectstack-ai/objectui#11615, which names it. - **An empty-string option `value` parses, and a select may refuse it** (the fix round's out-of-scope note, carried here at the at-tier review's request). The runtime option's `value` is `string | number | boolean`, as objectui's runtime option declares, so `''` parses. Neither the built-in select (`form.tsx:3976`) nor `SelectField` (`:212`) guards an empty item value, and Radix Select refuses one. Refusing `''` on the shared element would over-refuse radio and checkbox options, and no writer authors it. Inference, not reproduced. Carrier: none. ## Deviations - **The commit trailers follow AGENTS.md** (the model-free trailer pair), not the harness reminder, which names a model. This body was edited through the relay's `issue_patch` (`PATCH /issues/21742`) and sent without a footer. The first edit was stored byte-identical (32375 bytes sent and stored, read back over REST), so no footer was appended and none is carried; the attribution is the session named under the fix round above. - **Two files outside the claim's file list are edited,** each because the change reds it otherwise: `type-alias-convention.pin.test.ts` (the ADR-0122 route) and `dropped-refinements.baseline.json` (the ledger's own refusal printed the corrected entries). The fix round adds a third, `packages/qa/dogfood/test/expression-conformance.ledger.ts`, by the seat's order. - **The `{ field }` arm reads `FormFieldSchema`'s object half off its pipe** (`.in.shape`), because `view.zod.ts` is not on the file surface and exports no base. One place does this. §3 pins every reused member's def against the form view's, so the read cannot drift unnoticed. - **The option's `visibleWhen` is a new declaring position, not the object field's option's by reference.** That one's describe and its ledger row (`cel-select-option-visible`) state the server's re-check on write, which an inline option never gets. So the option declares its own predicate, and the ledger carries a fifth row beyond the four the order named. - **The census instrument is this run's own,** extended in the fix round with `.map` evaluation. Its counts differ from the S-objectui-held stage's (27 `customFields` values, 7 inline section entries), because pass 2 and the designer's code-composed nodes are counted here. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ea7ff39 commit 1289925

14 files changed

Lines changed: 1375 additions & 75 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an `object-form` page block's `customFields` takes a closed runtime form field, and the `sections` of `object-form` and `object-master-detail-form` take a page-block section shape, instead of any value (#21464)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered ui-object-form-custom-fields-typed ui-object-form-sections-typed -->
10+
11+
**BREAKING** — two accept-set narrowings on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the rows: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path.
12+
13+
**`@objectstack/spec`**
14+
15+
- **`object-form` `customFields` is a list of closed runtime form fields.** It was `z.unknown()`. Each member is the field the form merges over the fields it generates from the object's metadata and draws as written, and the spec now declares it: `name` (its identity), `label`, `description`, `type`, `inputType`, `widget`, `required`, `disabled`, `readonly`, `hidden`, `placeholder`, `options`, `validation`, `dependsOn`, `visibleWhen`, `readonlyWhen`, `requiredWhen`, `colSpan`, `span`, `group`, and the metadata a field widget reads off it — `multiple`, `rows`, `accept`, `dimensions`, `reference`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `returnType`, `summaryOperations`, `columns`. Members this package already declares take that declaration by reference (the object field's own, the evaluated predicates); `label`, `description` and `placeholder` are plain strings. An `options` entry is the runtime option the form's option controls draw, closed: `label`, `value`, `description` (a lookup searches it), `visibleWhen` (the cascade offers the option only while it holds). Its `value` is a string, a number or a boolean, kept as written — an inline field binds no object column, so a stored field's lowercase-identifier rule does not apply, and `{ label: 'Box', value: 'Box' }` parses.
16+
- **The `sections` of `object-form` and `object-master-detail-form` are one page-block section shape.** They were `z.array(z.unknown())`. A section takes the form view's section keys — `name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`, `columns`, `pane`, `group`, `fields` — and the form view's group-reference rule; each `fields` entry is a field name, the form view's `{ field, … }` entry, or an inline runtime form field (the `customFields` member). The stored form view's `FormSectionSchema` is unchanged.
17+
- **Canonical spellings only.** A page block's `properties` is never parsed on the way to the form, so a form view's parse-time folds do not run there: a section `visibleOn` and a string `columns` reached the form raw and were dropped. Both are refused with the canonical spelling, and so is a `{ field }` entry's or an inline field's `visibleOn`.
18+
- **Refused with what to write instead:** an inline field's legacy `condition`, its `defaultValue` (which seeds nothing), `id`, a `fields` member claim, the `grid` widget's eight snake_case keys (`min_rows`, `max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, `add_label`, `sort_field` — they come in once the widget reads a camelCase spelling), a boolean `validation.required`, a `validation.pattern` / `validate` rule, an option's `color`, `default`, `disabled` or `icon` (no form option control reads them), and a locale map where the form draws a plain string.
19+
- **`ObjectFormProps`, `ObjectMasterDetailFormProps`** and their parsed types carry the field and section types on these members instead of `unknown`; the shapes themselves are module-private. A bare CEL `visibleWhen` parses to its `{ dialect, source }` envelope, as on every evaluated slot, so the `object-form` row's input and parsed types now differ and it gains the one new export, the type `ObjectFormPropsParsed` (ADR-0122), as `ObjectMasterDetailFormPropsParsed` already is.
20+
21+
## FROM → TO
22+
23+
| you wrote | write instead |
24+
|:--|:--|
25+
| `customFields: [{ name: 'b', visibleOn: "record.a != ''" }]` | `customFields: [{ name: 'b', visibleWhen: "record.a != ''" }]` |
26+
| `customFields: [{ name: 'b', condition: { field: 'a', equals: 'x' } }]` | `customFields: [{ name: 'b', visibleWhen: "record.a == 'x'" }]` (`notEquals` is `!=`, `in: [ … ]` is `record.a in [ … ]`) |
27+
| `customFields: [{ name: 'memo', defaultValue: 'X' }]` | `customFields: [{ name: 'memo' }], initialValues: { memo: 'X' }` |
28+
| `customFields: [{ name: 'a', validation: { required: true } }]` | `customFields: [{ name: 'a', required: true }]` (a string `validation.required` is the message) |
29+
| `customFields: [{ name: 'a', validation: { pattern: { value, message } } }]` | `customFields: [{ name: 'a', pattern: '^[A-Z]+$' }]` |
30+
| `customFields: [{ name: 'items', type: 'grid', min_rows: 1 }]` | `customFields: [{ name: 'items', type: 'grid', columns: [ … ] }]` — the widget's defaults until it reads a camelCase key |
31+
| `customFields: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold', default: true }] }]` | `customFields: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }] }], initialValues: { tier: 'gold' }` |
32+
| `options: [{ label: 'Gold', value: 'gold', color: '#d4af37' }]` on an inline field | `options: [{ label: 'Gold', value: 'gold' }]` — a colour belongs on the object field's own option |
33+
| `sections: [{ fields: ['a'], visibleOn: 'record.b == 1' }]` | `sections: [{ fields: ['a'], visibleWhen: 'record.b == 1' }]` |
34+
| `sections: [{ fields: ['a'], columns: '2' }]` | `sections: [{ fields: ['a'], columns: 2 }]` |
35+
| `sections: [{ fields: [{ field: 'a', visibleOn: '…' }] }]` | `sections: [{ fields: [{ field: 'a', visibleWhen: '…' }] }]` |
36+
| `sections: [{ label: { en: 'Basics' }, fields: ['a'] }]` | `sections: [{ name: 'basics', label: 'Basics', fields: ['a'] }]` — the heading translates through `objects.<object>._sections.basics.label` |
37+
38+
The one-line fix: write each inline field in camelCase with the members the form draws and each section in its canonical spelling. No conversion is registered: nothing on the load path refuses either shape, and the census below found no working value to respell — the D3 entries `ui-object-form-custom-fields-typed` and `ui-object-form-sections-typed` carry that judgment.
39+
40+
## Who is affected, measured
41+
42+
A writer is a value written on the block: a page-component node (an object literal naming `object-form` or `object-master-detail-form`, flat or in its `properties` bag, or a literal annotated as one), a direct parse through the row, the block's React component inside `schema={{…}}`, the argument a local helper passes in that position at every same-file call site, or — the second pass — any object literal carrying `customFields` or `sections` in a file that names a form block. Values resolve through same-file constants and spreads, and through a `.map` over a constant list — the first run of this census read such a list as non-static and so never parsed the object manager's options; that miss is why an inline option's `value` is now a runtime value. Every static value was parsed through this branch's rows, and each value with a non-static part, and each refusal, was read by hand.
43+
44+
- **objectstack** at `316be321ef`, this branch's merge base: three `object-form` `sections` writers (the showcase's new-project wizard, and one test each in `lint` and `spec`), field names only — all parse. No `customFields` writer. The other `sections` the second pass finds are form views and `record:details` sections, which these rows do not judge.
45+
- **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `fd060f076` (every cited reader file is byte-identical between the two; `main` adds three test values, which parse): **`customFields`** — 31 values parse (four block literals; the designer's object manager, whose `icon` and `group` options `{ label: 'Box', value: 'Box' }`, `{ label: 'Custom Objects', value: 'Custom Objects' }`, … parse as runtime option values — typed as the form view's option, a stored field's lowercase identifier, both were refused; and 26 helper and embeddable-form arguments) and two are refused, both probes: a type-level test's `visibleOn` (never drawn) and the fixture pinning that an inline `defaultValue` seeds nothing; the 11 fully non-static values are run-time hand-offs and helper parameters, read by hand. **`sections`** — 102 `object-form` values with a static part parse (the field designer's inline fields and the plugin-form README's inline-field wizard among them), and so do four `object-master-detail-form` values. Two are refused, both probes of shapes objectui's own renderer test says this door refuses at parse: a section declaring neither `fields` nor `group`, and a group-owned `label` / `collapsible` beside `group`. The 26 fully non-static values are run-time hand-offs and helper parameters, read by hand: they use declared keys only, but for objectui's probe that a retired `className` / `gridClassName` reaches nothing. The second pass's other refused section values are `record:details`, detail-view or object-view form-slot sections, which these rows do not judge.
46+
- **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no writer of either member.
47+
- **Deployed metadata** was not measured.

0 commit comments

Comments
 (0)