Repository navigation
feat(spec)!: object-form customFields takes a closed runtime form field, and both forms' sections a page-block section shape (#21464, S-forms) - #21742
Conversation
…he ruled shapes (#21464, S-forms) [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…ers; ledger the new dropped-refinement sites [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…s, api-surface and export-origins [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
… line [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…p-reference probe values; the changeset counts helper parameters Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b6f02595a7c447b43f044238138e6061dffda98a && git checkout b6f02595a7c447b43f044238138e6061dffda98a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ff29410ed2133d8f6bcf955960f606e6bfbdbf54 716f4c6522652f16c82c086f6c11f77cc06b4c7a && git checkout -B drift-repro ff29410ed2133d8f6bcf955960f606e6bfbdbf54 && git merge --no-ff 716f4c6522652f16c82c086f6c11f77cc06b4c7a
node scripts/docs-audit/affected-docs.mjs --json ff29410ed2133d8f6bcf955960f606e6bfbdbf54
|
…object-manager dialog, and a closed option element [wip, red first] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
… the form draws — label, value (a string, a number or a boolean), description, visibleWhen — closed [wip]
The option element was the form view's (FormSelectOptionSchema), whose value is
a stored field's lowercase identifier; the shipped object-manager dialog's
{ label: 'Box', value: 'Box' } options were refused.
Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn
Co-authored-by: Claude <noreply@anthropic.com>
…ction and option predicates in the expression conformance ledger [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…unts for the runtime option [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…n census — option lists evaluated, the object-manager writer parses, the sections refusals are the group-reference probes [wip] Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…ne section entry, and `ObjectFormSection.fields` gains the form view's `{ field }` arm (objectui#11615) (objectstack-ai#11622)
Fixes objectstack-ai#11615
Clause-②: yes
## What this does
The default (`simple`) `object-form` now draws a self-describing inline
section entry the same way the `tabbed`, `wizard`, `split`, `drawer` and
`modal` forms already did. `ObjectFormSection.fields` now declares the
form view's `{ field, … }` entry. This carries out triage's first grade
as ruled (comment `5980751840`, Option 1).
### What it accepts now, and the type gain (Clause-② widening)
- **`@object-ui/types`.** `ObjectFormSection.fields` is `(string |
SpecFormFieldInput | FormField)[]`.
- The new arm is `@objectstack/spec`'s `FormFieldInput`, by reference.
It is the authoring face of `FormFieldSchema`, the entry that the spec's
`FormSectionSchema.fields` takes beside a bare name.
- The zod mirror is unchanged: a section's `fields` entry is still
`z.any()` there.
- **Breaking for TypeScript readers of `ObjectFormSection.fields`**
(still `minor`, as the changeset states).
- Code that narrowed an entry with `typeof entry === 'string' ? entry :
entry.name` no longer compiles, because the form view's `{ field }`
entry has no `name`. Before the gain, that read returned `undefined` on
such an entry without a type error.
- The remedy is to name each entry by its own arm through
`sectionEntryName`. Patch round 1 (`31a9d9a`) publishes it from the
`@object-ui/plugin-form` entry, beside `resolveSectionGroupReferences`,
whose result it reads. That is a further `Clause-②` widening, named in
the changeset.
- The one in-repo reader, an app-shell test, is fixed that way.
- **`@object-ui/plugin-form` types.** The five layout section configs
now take NonNullable of `ObjectFormSection['fields']`, by reference:
`FormSectionConfig` (tabbed), `WizardStepConfig`,
`SplitFormSectionConfig`, `DrawerFormSectionConfig` and
`ModalFormSectionConfig`.
- This change is forced. `ObjectForm` passes an authored section's
`fields` to these configs unchanged, so without it the ruled type gain
does not compile: `tsc` reported TS2322 at each of the five places
`ObjectForm` passes them.
- Each of these layouts already drew the `{ field }` entry through
`buildSectionFields`. The only alternative was a cast, which would hide
what these layouts draw.
- **`@object-ui/plugin-form` runtime, `simple` form only.** Three
changes:
1. **Drawing.** `buildSectionFields` with a field pool now draws any
entry that `isInlineFieldDef` accepts, whatever the pool holds, and
draws it as written. The branch with no pool gives the same result.
`isInlineFieldDef` accepts an object whose `field` is not a string and
whose `name` is a string.
2. **Submit.** The no-data-source submit exception now uses the shared
`hasInlineFieldSource`. Before, it used `hasInlineFields`, which is true
only for a non-empty `customFields`.
3. **Opening values.** With no data source, a form whose sections are
all inline now falls back to an inline-only schema. That form now opens
on `initialValues` / `initialData`, as the five other layouts do.
### What stays refused or warned
- A bare name and a `{ field }` entry still resolve against the field
pool on `simple` (the objectui#9884 intersection of `fields` and
`sections`). If the pool lacks the name, the entry is dropped, and a
warning is logged once when the object declares the field.
- The warning now skips inline entries, because those are drawn. Warning
about them would report a loss that did not happen.
- An inline entry with no `name` is malformed. A form with a field pool
still does not draw it.
- With no `submitHandler` and no data source, the submit still refuses
with `DataSource is required for form submission (inline mode not
configured)` unless every section entry is inline. One name among inline
entries still refuses, and this is now pinned on all six layouts.
- The `object-form` element gate is unchanged. See Acceptance notes.
**Behaviour change for an existing schema.** On `simple`, take an inline
entry whose name the object declares but top-level `fields` leaves out.
Before, it was dropped, with the intersection warning. Now it is drawn
as its own definition, with no warning, as on the other five layouts.
With a data source, its value is written as usual. As on every form, a
value for a field the object does not declare is stripped from the
write.
### Zone 2 item 2: the submit refusal, measured
Measured with a throwaway probe (not committed) and the ablations below.
The form is `simple` with all-inline sections, no `customFields`, no
data source and `initialValues: { ref: 'SEED' }`:
| tree | fields drawn | `ref` opens as | submit |
|:--|:--|:--|:--|
| base `9db9ff3` | none | (no control) | `onError` with the refusal |
| drawing change, old submit check (ablation A3) | `ref` | — | `onError`
with the refusal, after collecting a value |
| drawing + shared submit check, no inline-only fallback (ablation A4) |
`ref` | `''` | `onSuccess` |
| this PR | `ref` | `'SEED'` | `onSuccess` with the collected values |
| `tabbed` / `drawer` on base, the same form (probe) | `ref` | `'SEED'`
| `onSuccess` with the collected values |
**Decision: `simple` now counts all-inline sections as an inline field
source, using `hasInlineFieldSource`.** The code and the family
precedent decide this:
- The old comment in `handleSubmit` justified the `simple`-only
exception with one premise: "sections only SELECT pooled fields, so a
sections-only form with no adapter resolves zero fields". This card
removes that premise. With the drawing change alone, the form draws and
collects its field and then refuses, while the five other layouts accept
the same form. That would be a second contract that only `simple`
follows.
- The README section "What a form submits to" already declares that
two-limb rule as uniform across all six layouts, `simple` included.
- The `object-form` element gate already uses `hasInlineFieldSource` for
`requiresObject`, for every form type.
- The refusal is still loud wherever a form really has nowhere to write.
The second limb accepts only sections in which every entry is inline.
One name or `{ field }` entry means the form needed metadata it could
not get, and it refuses.
**The boundary case, before and after.** `submitTargetRefusal.test.tsx`
changes as follows:
- **Before:** `formType simple: BOUNDARY — sections of inline fields are
NOT its field source` pinned the refusal of a form that drew nothing.
- **After:** that row is replaced by a six-layout row, `the
inline-sections collector opens on initialValues and submits what it
holds`. `simple` also joins the six-layout row `BOUNDARY — one bare
field name among inline ones still refuses`. The inline-sections row
`sections of inline runtime fields — the README's own shape — still
work` now runs all six layouts.
### Zone 2 item 4: "carries its own `type`" is not the right test for
"self-contained"
- With no pool, a section entry counts as an inline field whenever its
`field` is not a string. That path does not check `type`.
- The spec's inline arm requires `name` and leaves `type` optional ("the
renderer's default input when omitted"). That arm is
`objectFormRuntimeField` in objectstack's `component.zod.ts`, landed by
objectstack-ai/objectstack#21742.
- This package already had a predicate for "self-describing":
`isInlineFieldDef` in `submitTarget.ts`, which the submit-target rule
reads. This PR exports it and reuses it, so one predicate answers both
"needs no adapter" and "needs no pool".
- A `{ name, label }` entry with no `type` draws the default input on
all six layouts. This was measured, and the new pin covers it.
Testing for `type` would have kept skipping a spec-legal typeless inline
entry on `simple` while the other five layouts draw it. That is the
divergence this card closes, and it would also have added a second
predicate.
### Zone 2 item 3: no maintainer ruling forbids drawing
- **objectui#9884:** the timeline shows four comments, all from the
execution seat (claim, two dev reports, landing verdict), and none from
a maintainer. The issue's comment count says five, but the timeline
lists only four comment events. The warning docblock that objectstack-ai#9884 landed
limits the intersection to members the object declares.
- **objectui#11550:** its ruling is triage's (`5969880008`). It keeps
the inline section entry, with "No narrowing that refuses the documented
wizard".
### File surface: what the claim did not list, and why
The claim covers `sectionFields.ts`, `ObjectForm.tsx`,
`submitTargetRefusal.test.tsx` and the tests beside it,
`ObjectFormSection.fields` and the changeset. The PR also touches these
files:
- `submitTarget.ts`: exports `isInlineFieldDef` (Zone 2 item 4: use the
same predicate).
- `TabbedForm.tsx`, `WizardForm.tsx`, `SplitForm.tsx`, `DrawerForm.tsx`,
`ModalForm.tsx`: one type line each, needed for the ruled type gain to
compile (see above).
- `index.tsx`: the `object-master-detail-form` `fields` registration
description said that any section member `fields` does not list is
dropped. This change makes that false for inline members, so the
sentence is corrected in the same PR.
- `packages/types/src/zod/objectql.zod.ts`: a comment that names the
declared entry type.
- `scripts/check-spec-symbol-derivation.mjs`: removes
`ObjectFormSection` from `CLAIM_DEBT`. Once `fields` referenced a spec
type, `check:spec-symbols` failed on that stale entry and asks for the
removal. `--claim-ledger` prints the same block.
- `packages/plugin-form/README.md` and
`content/docs/plugins/plugin-form.mdx`: a short "What a section's
`fields` entries draw" section, required by AGENTS.md §5 objectstack-ai#2.
- Patch round 1 (`31a9d9a`):
-
`packages/app-shell/src/views/metadata-admin/SchemaForm.groupSectionReachability-8725.test.tsx`:
the one downstream reader the type gain broke. It now names entries with
`sectionEntryName`.
- `index.tsx`: publishes `sectionEntryName`, and corrects the
`object-form` element gate's docblock (comment only).
- The README and the mdx page: how to read an entry.
- New tests:
`packages/plugin-form/src/__tests__/inlineSectionEntry-11615.test.tsx`
and
`packages/types/src/__tests__/object-form-section-field-entry-11615.test.ts`.
## Verification
**Patch round 1, on head `31a9d9a`:**
- **Downstream type-check sweep.** `turbo run type-check
--filter='...@object-ui/types'` covers `@object-ui/types` and its 41
dependents that have a type-check script.
- It was reproduced on `a49b506` first: 76 of 77 tasks passed, and the
single error was the app-shell test's TS2339.
- On `31a9d9a`: 77 of 77 tasks passed, exit 0.
- **app-shell:** the 25 `SchemaForm*.test` files: Test Files 25 passed,
Tests 301 passed.
- `check:readme-exports`, `check:doc-snippets` and `check:doc-examples`
were measured green this round.
The readings below were taken on head `a49b506`, the branch merged with
origin/main `c096f03`, and are unchanged by patch round 1. Round 1
re-ran the `plugin-form` and `types` suites green on `31a9d9a`.
- **`plugin-form` tests** (`pnpm exec vitest run
packages/plugin-form/`): Test Files 164 passed (164), Tests 1906 passed
and 1 skipped (1907).
- **`types` tests** (`pnpm exec vitest run packages/types/`): Test Files
357 passed (357), Tests 9500 passed (9500).
- **Tests for the edited gate script.** The 12 tests under
`scripts/__tests__` that name `check-spec-symbol-derivation`: Test Files
12 passed (12), Tests 343 passed (343).
- **Type checks:** `pnpm --filter @object-ui/types run type-check` and
`pnpm --filter @object-ui/plugin-form run type-check` both exit 0, after
`turbo run build --filter=@object-ui/plugin-form^...` built the
dependencies. `--listFilesOnly` confirms that both new test files are in
their package's test program.
- **Lint:** `@object-ui/plugin-form` and `@object-ui/types` report 0
errors.
- **Root gates, all exit 0:** `check-changeset-presence`,
`check-changeset-no-major`, `check:changeset-claims`,
`check:pending-changeset-literals`, `check:spec-symbols`,
`check:doc-types`, `check:doc-fences`, `check:doc-example-ids`,
`check:new-line-citations` (0 new), `check:control-bytes`,
`check:test-path-roots`, `check:vi-mock-specifiers`,
`check:vi-mock-inherit`, `check:vi-mock-override-shape`,
`check:element-data-source-declaration`, `check:unreferenced-sources`,
`check:prompt-keys`, `check:installed-pin-claims` and
`check:skills-paths`. `check-governed-queue-guard --test` over the 19
paths reports NOT GOVERNED.
- **NOT MEASURED, left to CI:** `check:sdui-registration-pins`, which
needs a console build. `check:doc-snippets`, `check:doc-examples` and
`check:readme-exports` were measured green in patch round 1.
**Ablations.** Each mutation went through `ablation-replace.mjs`, and
each was restored with the blob equal to HEAD and `git diff HEAD` empty:
- **A1.** `ObjectFormSection.fields` set back to `(string |
FormField)[]`. The `types` test program fails with TS2322, only on row 1
of the new types pin (the `{ field }` entry).
- **A2.** The pooled branch's inline draw deleted. 7 tests fail, all of
them `simple` rows: the six-layout drawing row, both default-form
intersection rows, the three `simple` rows in `submitTargetRefusal`, and
the unit pin. The other five layouts stay green.
- **A3.** The submit check set back to `hasInlineFields`. 2 tests fail:
the `simple` "still work" row and the collector row. `simple` BOUNDARY
stays green, as expected.
- **A4.** The no-data-source fallback set back to `hasInlineFields`. 1
test fails: the `simple` collector row, with `expected '' to be
'SEEDED'`.
## Acceptance notes
- **Element gate (not changed here).** With an `objectName` and no data
source, the `object-form` element gate requires a data source unless
`customFields` is non-empty. All six layouts now treat all-inline
sections as an inline source. So on the page-block route, such a form
gets the gate's notice before it reaches any layout. This errs on the
loud side and is the same for every form type. Owner: none. Patch round
1 corrected the gate's docblock so it no longer claims to be in step
with the layouts (comment only). Aligning the gate itself stays outside
this card.
- **Values written for undeclared fields.** With a data source, a value
collected by an inline section entry whose name the object does not
declare is stripped from the write, on all six layouts.
`formWritePayload` passes the object definition unless `customFields` is
non-empty. This is unchanged and by design ("keys the object does not
declare"). Owner: none.
Session: `https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL`
---
_Generated by [Claude
Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Part of #21464
Clause-②: yes (narrowing)
What this does
The S-forms stage of the
ComponentPropsMapz.unknown()close-out. It executes the maintainer's rulings on forks 2 and 3 of the decision card #21704 (ruling record5978663135, batch #276, letters B and B), per the claim5979114945. Read points are at the.objectui-shapin2e818d0b51ec, under objectuipackages/unless named. Every cited reader file is byte-identical at objectuimainfd060f076, and the pin is an ancestor of thatmain.object-form·customFieldsz.unknown()name, only the members the form draws; itsoptionsentry is the closed runtime option the form's option controls drawobject-form·sectionsz.array(z.unknown())object-master-detail-form·sectionsz.array(z.unknown())sectionsto 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(record5980612890) answered FAIL on one point, and the seat's order5980628111took its remedy 1. This round, by the sessionsession_016tKoy8NJa35Yih1FdzrVmn(the director seat's dispatch), changes:optionson the runtime form field is re-typed. It took the form view's option (FormSelectOptionSchema), whosevalueis a stored field's lowercase identifier, so the shippedobject-managerdialog's options ({ label: 'Box', value: 'Box' }, objectuiplugin-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),descriptionandvisibleWhen. Every other ruled member is unchanged..mapover a constant list as non-static and so never parsed the object manager's options; the instrument now evaluates it.expression-conformance.test.tsreported four UNCLASSIFIED surfaces (buildObjectFormRuntimeField.visibleWhen/.readonlyWhen/.requiredWhen,buildObjectFormSection.visibleWhen). They are classified inexpression-conformance.ledger.tsas rowcel-form-block-field-rule, and the new option'svisibleWhenas rowcel-form-block-option-visible, in the shape of the existingui/component.zod.tspage-block rows (feat(spec): ComponentPropsMap rows for action:button/group/menu/icon and element:definition-list/repeater #20420 is the precedent for a spec-lane edit of that ledger).simpleform type, an inline section field entry whose name is not in the field pool is skipped, while every other arm draws it, and objectstack now admits that entry on the page block 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 fromObjectForm.tsx:1178-1185and from every otherformTypearm, mergescustomFieldsover 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.name,label,description,type,required,disabled,readonly,hidden,validation,visibleWhen,readonlyWhen,requiredWhen,colSpanform.tsxrenderFormFielddestructure:2675-2693;hidden:2696; the three rules:2732;validation:2795;colSpan:2988widget,multiple:2915-2918(widgetahead oftype, arity frommultiple)options,dependsOn:2934-2944(the cascading option list)placeholder,inputType:3185,:3174(the built-in input'stype)span,colSpanplugin-form/src/autoLayout.ts:162-172groupplugin-form/src/fieldGroups.ts:52(the object's field-group sections are derived over the drawn fields)rows·accept,multiple·dimensions·reference·min,maxfields/src/widgets/TextAreaField.tsx:102·FileField.tsx:147-148·VectorField.tsx:11·LookupField.tsx:326·NumberField.tsx:88-89minLength,maxLength,patternform.tsx:4080,:4146;patternrides onto the native control)returnType·summaryOperations·columnsFormulaField.tsx:22·SummaryField.tsx:15·GridField.tsx:588-589The option, measured from the option readers. Every option control the form reaches reads the same four keys:
label,valueform.tsx:3975-3977, the pick mapped back to the authored value bymatchOptionValue,:3955) and the four option widgetsSelectField,MultiSelectField,RadioField,CheckboxesField, which drawoptionDisplayLabel(core/src/evaluator/optionRules.ts:173) and stringifyvalueonly at the controlvisibleWhenresolveCascadingOptions→resolveVisibleOptions(optionRules.ts:97-110), fromform.tsx:2940and from each widget'suseCascadingOptionsdescriptionfields/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'sdisabled(read only by the standaloneselectnode renderer,components/src/renderers/form/select.tsx:91, which a form field never reaches) andicon(read by no option control on the form path) are refused, each with a prescription. Aliases namelabel(text,name,title),value(key,id) andvisibleWhen(visible,showWhen).Value types. Where this package already declares a member, its value schema is taken by reference: the object field's
FieldSchemamembers forrows,accept,dimensions,reference,minLength,maxLength,returnType,summaryOperationsandcolumns(inlineColumns);EvaluatedExpressionInputSchemafor the three*Whenrules; and the object field'sdependsOnlist beside a bare name. The option'slabelanddescriptionare the object field's option's own (SelectOptionSchema.shape, pinned def by def). ItsvisibleWhenis 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.grouptakes the field-group key grammar (SectionGroupKeySchema).label,descriptionandplaceholderare plain strings, because the renderer draws each as it stands and an inline locale map would be a React child.validationis{ required?, minLength?, maxLength?, min?, max? }. Each bound rule is{ value, message }.requiredis 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:
gridwidget'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 types(fields): thegridwidget's eight field-level keys are snake_case (min_rows,allow_add,total_field, …), so the spec's runtime form field cannot declare them under its camelCase rule objectui#11610.visibleOn(form.tsx:2767) and the legacycondition(:2717), two more spellings of the conditional-visibility predicate. ADR-0089 D1 makesvisibleWhenthe single canonical key. The only measuredvisibleOnwriter is a type-level test that never draws (below).id: the renderer keys the row byid ?? name(:2898), andnameis 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:
groupis read (above), so it is typed.defaultValueis not read, so it is refused. The form opens oninitialValuesand on the object's declared defaults (schemaDefaults.ts), and objectui'sinitialRecordMerge-9760.test.tsxrow 7 pins that an inlinedefaultValueseeds nothing. The prescription moves the value into the block'sinitialValues.Fork 3 B — the page-block section shape
Section keys, read:
nameandlabel: the heading (ObjectForm.tsx:1693and the per-formTypemaps 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'sObjectFormSectiondeclares 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
propertiesis never parsed on the way to the form, so the form view's two folds do not run there, and the form reads onlyvisibleWhenoff a section and only a numericcolumns(clampCol,:1599). A sectionvisibleOnand a stringcolumns: '2'were therefore dropped in silence. Both are refused with the canonical spelling.labelis 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 AREFormFieldSchema's object half, pinned def by def, with three differences that follow from how the page block reaches the form:visibleOnis refused, as above (the form readsvisibleWhen ?? visibleOn,sectionFields.ts:455);label,placeholderandhelpTextare plain strings (copied onto the drawn field as they stand,:386-388);spandrops 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. Soobject-form's input and parsed types now differ, and the row leaves the type-alias pin's isomorphic family for anObjectFormPropsParsedalias (ADR-0122), asobject-master-detail-formdid 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.jsonand fenced code in.md/.mdx. It finds:type(flat or inproperties), literals annotated or asserted asObjectFormSchema/MasterDetailFormSchema, the block's React component'sschemaprop, 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.customFieldsorsectionsin a file that names a form block.Values resolve through same-file constants and spreads, and, new in this round, through a
.mapover 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'scomponent.zod.ts, refuseObjectManager.tsx:239members 4 and 5 (icon,group) at everyoptions.N.value(invalid_format, the identifier rule). Through this head's rows they parse.customFieldssections(object-form·object-master-detail-form)316be321ef(the previous merge base; the three commitsmaingained since add no writer)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.2e818d0b51ecmainfd060f0764054ec26802205b53010customFieldsat objectui:guideCrudAppRenders.test.tsx:170,objectFormCustomFieldsMembers-8071.test.tsx:191andsubmitTargetRefusal.test.tsx:155,:317;ObjectManager.tsx:239, the registeredobject-managercomponent's modal form. Its labels aret(...)calls (non-static); itsiconandgroupoptions are now evaluated fromICON_OPTIONSandOBJECT_GROUPS('Box','ShoppingCart','Custom Objects', …) and parse as runtime option values;EmbeddableForm.tsx:567hands to the form ascustomFields. Among them: the merge pins, the sections-and-members pins, the mobile fullscreen pin withrows/placeholder/field:textarea, the field-group row withgroup, andcontent/docs/guide/public-forms.md.main,apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx:78and:84(a{ name, label, type }field each) parse as well.types/src/__tests__/p1-spec-alignment.test.ts:348: a type-annotated literal whosevisibleOn: '${data.industry != null}'is never rendered and is not CEL.plugin-form/src/__tests__/initialRecordMerge-9760.test.tsx:236: thememomember whosedefaultValuethe test's row 7 pins as seeding nothing.EmbeddableForm.tsx:567,ObjectView.tsx:2599, the arm forwards inDrawerForm,ModalForm,ObjectForm,SplitForm,TabbedForm,WizardForm) and helper parameters. No othercustomFieldsvalue has a non-staticoptions.sectionsat objectui:plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx:270,:307) states that this door refuses both shapes at parse: a section declaring neitherfieldsnorgroup("off-spec, so reachable only from a programmatic SDUI caller"), and a group-ownedlabel/collapsiblebesidegroup, which the renderer reports and ignores. These are the form view's own group-reference rule.plugin-designer/src/FieldDesigner.tsx:344:name,label,type,required,placeholder,disabled,options,visibleWhen). Its one non-staticoptions,flatTypeOptions, was read by hand:{ label: FIELD_TYPE_META[ft].label, value: ft }per field type, two keys the option declares;inputType);{ field }entry (cli/src/__tests__/spec-vocabulary-hint.test.ts:54);columns,paneandvisibleWhensections;main,objectname-neither-hint-11605.test.tsx:210(an inline field in a section).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 retiredclassName/gridClassNamereaches nothing (refused here, as objectui's own type refuses it).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:validationblock, 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 alazySchema, for the alias-integrity walk's reason, asobjectGanttMarker());ObjectFormPropsParsed;FormFieldSchema;FieldSchemaandSelectOptionSchemafrom../data/field.zod).packages/spec/src/ui/component-props-unknown-members.pin.test.ts:customFieldsand bothsections[]fork lines leave;customFields[]/sections[].fields[]visibleWhen/readonlyWhen/requiredWhen, an option'svisibleWhen, a grid column's two rules, andsections[].visibleWhen);summaryOperations.filter{}gets its ownsharedreason (FILTER_CONDITION: a querywhereover the child object's fields, judged by the filter schema's own refinement);forkstage's text drops the form field.packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts(new):'Box','ShoppingCart',1,2,true,false, and an option'sdescriptionandvisibleWhen), the shippedobject-managerdialog's inline fields byte-identical, the predicate envelope (an option's barevisibleWhenincluded) and the absent case.codeandpath(the fix round adds an undeclared option key, an optioncolor, an optiondefault, an option with nolabel, and an objectvalue), plus the prescriptions, the option's included.description,label,value,visibleWhen; not the form view's option;labelanddescriptiondef-identical to the object field's option;valueaccepts a string, a number and a boolean and refusesnull,undefined, an object and an array), no snake_case key, the form view's section keys minusvisibleOn, the{ field }arm's members def by def, the inline arm's identity and the shared section instance.packages/qa/dogfood/test/expression-conformance.ledger.ts(fix round): rowscel-form-block-field-rule(the inline field's three rules and the section'svisibleWhen: objectuiresolveFieldRuleState→evalFieldPredicate, fail-soft-log) andcel-form-block-option-visible(the option'svisibleWhen:resolveCascadingOptions, fail-soft-log, UI gating only).packages/spec/src/type-alias-convention.pin.test.ts: theObjectFormPropsSchemaIso 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, whichz.toJSONSchemadrops: the section's group-reference rule, the inline grid column's rules, and the roll-up filter's comparand refinement.droppedRefinementSitesgoes 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.18.ui-object-form-custom-fields-typed.tsand18.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 theformSectionGroupReference-7051probes as the refused values);registry.ts: the semantic region is regenerated, and the step-18 rationale fragments sit at orders 78 and 79.RETIRED_KEYS_BY_MAJORrow: page-componentpropertiesis not parsed on the save or load path, and the refused values are nested member values.content/docs/references/ui/component.mdx(two member rows and three nested-shape tables; theoptionsrow now prints the runtime option),docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md(ui/198 → 204 andcomponent.zod.ts68 → 74, the six new strict sites),api-surface/ui.jsonandexport-origins/ui.json(ObjectFormPropsParsed)..changeset/21464-component-props-form-custom-fields-sections-typed.md:@objectstack/specminor, a BREAKING banner, theClause-②line, the ADR-0087registeredmarker naming both ids, FROM → TO (two option rows added) and the census, corrected.Measurements
These are at head
716f4c6522(merge baseff29410ed2:origin/mainwas merged in throughscripts/pm/os-regen-merge.shwith no conflict, and after a rebuild every generated artifact checks up to date) unless named.component.zod.tsis blob373d03336dd9fromc04a77716cto the head. Heavy runs went throughscripts/pm/os-verify-lock.sh, eachVERDICT command-exitread, 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 evenvalue: 'a',too_smallunder the identifier rule), and an optioncolorwas accepted. On the fix (c04a77716c): 70 passed, and an undeclared option key is still refused (unrecognized_keysatcustomFields.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 noname, a misspelled member,visibleOn,defaultValue,min_rowsandvalidation.required: true; onobject-form,sections: [42], a sectionvisibleOn,columns: '2',className, a section with neitherfieldsnorgroup, a{ field }entry'svisibleOnand an inline entry's unknown key; onobject-master-detail-form, a sectionvisibleOnand[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-typecheckOK 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 atb473439752.Public door.
validateComponentPropsover the built lint and spec: a stack shaped like the object-manager dialog ('Box','Custom Objects',1andtrueoption values) reports 0 findings; an optioncolorand an optionbogusreportcomponent-props-unknown-keyatcustomFields.0.options.0.color(with the prescription) andcustomFields.0.options.1.bogus.Ablation, the fix round (at
e948519edd;component.zod.tsblob373d03336dd9, the head's). The driver wrapsnode scripts/ablation-replace.mjsin its ownEXIT INT TERMtrap on the absolute path, with the HEAD blob as the restore target and an empty hash read as failure. The pins import./component.zodfrom source and the conformance test scans source, so no build or dist preflight is owed.options: z.array(buildObjectFormRuntimeOption())→z.array(z.unknown()), anchor x1 → x030c27aa10dd2cel-form-block-option-visiblewith its cover emptied1009587f14b8UNCLASSIFIED surface — add a ledger row (ADR-0060): ui/component.zod.ts:buildObjectFormRuntimeOption.visibleWhenAfter each leg the blob equals HEAD and
git diff HEADis empty. The stage's three legs (at3c4c7ce1a8:customFields,object-formsectionsandobject-master-detail-formsectionseach toz.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 --commandsderived 114 commands at716f4c6522(14 paths, +1375 / −75, under the 5000 threshold). All 114 ran at that head. On the first pass 112 exited 0, andcheck:skill-examplesandcheck:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: this fresh worktree had no package builds); both exited 0 after the fullturbo run build(72 tasks, 71 of them from the shared turbo cache, exit 0). The--ranreconciliation reads: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN.Named extras, with this body as the
pull_requestpayload:check-changeset-no-major.mjs --base origin/main --eventexit 0 (declaration lineClause-②: yes (narrowing), armnarrowing);check-adr-0087-registration.mjsexit 0 (registered ui-object-form-custom-fields-typed, ui-object-form-sections-typed);check-empty-changeset.mjsexit 0.Narrowed lint.
eslint --no-inline-config --format jsonover the eight changed TS files: 8 files, 0 errors, 0 warnings.--print-configprintsundefined).eslint.config.mjsenables no type-aware linting (noparserOptions.project, its own note at about line 328), so this diff moves no untouched file's verdict.pnpm lintis 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:
pnpm lint. Reason: CI-owned; objectui was read at the pin and atmain, and not built against this spec.Acceptance notes (not filed)
ObjectFormblock's overlay typescustomFieldsasany[], and itssectionscome fromFormViewSchema(packages/spec/src/ui/react-blocks.ts, published asskills/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.paneoff a split form andgroup/visibleWhen/ atruecollapse pair on a wizard step, inFormViewSchema's own refinement. These are row-level rules couplingformTypeto the sections, not section-shape rules, so the ruled shape does not carry them. On a page block, objectui answers a wizardgroupwith an empty step and a warning. Carrier: none.FormFieldvocabulary:scaleandstep(NumberField,SliderField),language(CodeField),maxSize,captureandcrop(FileField,ImageField), andLookupField'sdisplayField/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.type/widgetnamespace rule objectui's authoring face enforces (a colon-qualified id must namefield:) is not carried; both are strings here. Carrier: none.{ field }entry'svisibleOn, 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.simplearm skips an inline section entry its pool lacks (the review's ③ note 4). OnformType: 'simple'(the default),ObjectFormpasses its field pool into section resolution (plugin-form/src/ObjectForm.tsx:1617-:1627), andbuildSectionFieldscontinues on an entry the pool lacks (sectionFields.ts:480-:484). So a self-contained inline entry whosenameneither the object norcustomFieldsdeclares 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 exceptsimple(plugin-form/src/submitTargetRefusal.test.tsx:116,:331-:358). The spec admits the inline arm on everyformType, as fork 3 B rules; the gap is the renderer's and does not change the ruled shape. Carrier: plugin-form: on the defaultsimpleform type, an inline section field entry whose name is not in the field pool is skipped, while every other arm draws it, and objectstack now admits that entry on the page block objectui#11615.ObjectFormSection.fieldsTypeScript type has no{ field }arm (the review's ③ note 5). It is(string | FormField)[], andFormFieldrequiresname, so objectui's own TS face has no arm for the form view's{ field }entry thatsectionFields.ts:373-455draws (its zod mirror takesz.any()there). Carrier: the same card, plugin-form: on the defaultsimpleform type, an inline section field entry whose name is not in the field pool is skipped, while every other arm draws it, and objectstack now admits that entry on the page block objectui#11615, which names it.valueparses, 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'svalueisstring | number | boolean, as objectui's runtime option declares, so''parses. Neither the built-in select (form.tsx:3976) norSelectField(: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
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.type-alias-convention.pin.test.ts(the ADR-0122 route) anddropped-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.{ field }arm readsFormFieldSchema's object half off its pipe (.in.shape), becauseview.zod.tsis 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.visibleWhenis 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..mapevaluation. Its counts differ from the S-objectui-held stage's (27customFieldsvalues, 7 inline section entries), because pass 2 and the designer's code-composed nodes are counted here.