Skip to content

Commit 958cfe2

Browse files
feat(spec)!: four object-form members take the shape the form reads; fields, sections held (#21464, stage 3) (#21590)
Part of #21464 Clause-②: yes (narrowing) ## What this does Stage 3 (S-form) of the `ComponentPropsMap` `z.unknown()` close-out, per triage `5961300594`, the seat answers `5963787404` (staging A) and `5966636964` (the writer test: a writer is a value the renderer draws), and the claim `5967737487`. Of the form family's nine `z.unknown()` members, **four are typed**. **Four are held** because the form draws a value each typed shape would refuse, and **one** (`customFields`) is re-recorded with the objectui-held contracts, because its entries are objectui's runtime form field, which the spec has not declared. Read points are at the `.objectui-sha` pin `89cad75d55`, under `packages/plugin-form/src/` unless named. | row · member | was | now | read point | |:--|:--|:--|:--| | `object-form` · `contentLayout` | `z.unknown()` | `'simple' \| 'tabbed'`, the measured shape | `ModalForm.tsx:854` tests `=== 'tabbed'` (declared `'simple' \| 'tabbed'` at `:151`); the only reader | | `object-form` · `submitBehavior` | `z.unknown()` | `FormViewSchema.shape.submitBehavior`, by reference | `ObjectForm.tsx:1312-1370` and `WizardForm.tsx:1005-1065` switch on `kind`, read `url` / `delayMs` / `title` / `message`; `submitRedirect.ts:202` judges a redirect `url` by parsing a form view through this same schema | | `object-form` · `navigateOnSuccess` | `z.unknown()` | `z.string()`, the measured shape | `successBehavior.ts:118-131` (`template.replace`), from `ObjectForm.tsx:1373` and `WizardForm.tsx:1071` | | `object-form` · `mobile` | `z.unknown()` | strict `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }` (module-private `ObjectFormMobileSchema`), the measured shape | `ObjectForm.tsx:1857-1942` (flat arm only) | | `object-form` · `fields` | `z.array(z.unknown())` | **held**, unchanged | `ObjectForm.tsx:961-981` and `flatFields.ts:71-79` draw a `{ name }` entry by that name | | `object-form` · `sections` | `z.array(z.unknown())` | **held**, unchanged | `sectionFields.ts:367-369` draws an inline runtime field `{ name, type, … }` inside a section as it stands ("shape 3") | | `object-form` · `customFields` | `z.unknown()` | unchanged; ledger stage `object-form` → `objectui-held` | `customFieldsMerge.ts:59-108` matches members by `name` and draws them whole | | `object-master-detail-form` · `sections`, `fields` | `z.array(z.unknown())` | **held**, unchanged | `MasterDetailForm.tsx:1692-1693` hands both to the parent `object-form` verbatim | The four typed members carry no default and no transform, so `ObjectFormProps` stays input-equals-output. `ObjectFormProps` carries the four types instead of `unknown`. ## The census (A1), whole A writer is a page-component node: an object literal naming the type, a literal annotated with the block's type, a `schema={{…}}` on `ObjectForm` / `MasterDetailForm`, a call into a local helper that builds the node (each call site counted, the helper's parameters bound to that call's arguments and defaults), or a direct parse through the row. Values resolve through same-file constants and single-expression local helpers. The control is `objectName` on the same nodes. The instrument is a TypeScript-AST walk over every `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.json`, `.md`, `.mdx` and `.yaml` file, with fenced code in the documents parsed too. | corpus | `object-form` nodes | `object-master-detail-form` nodes | control `objectName` | |:--|--:|--:|:--| | objectstack `e909aa0a23` (`examples/`, `packages/` with `packages/apps/`, `content/`, `skills/`, `apps/`) | 16 | 125 | 13 / 125 | | objectui `89cad75d55` (whole tree) | 539 | 131 | 522 / 120 | Re-checked at the final merge base `cc645f2385`: the `.objectui-sha` pin is unchanged, and the commits `main` brought since `e909aa0a23` add or remove no line naming either block (`object-form`, `object-master-detail-form`, `ObjectForm`, `MasterDetailForm`) or `submitBehavior`, `navigateOnSuccess`, `contentLayout` or `customFields` (0 hits in 2947 moved lines), so the census stands. Every renderer path that reads each member, and the keys it reads from an entry: | row · member | readers at the pin | keys read | referenced schema | objectstack values (parse) | objectui values · static (distinct) · parse · refused · not static | |:--|:--|:--|:--|:--|:--| | form `contentLayout` | `ModalForm.tsx:854` only | `=== 'tabbed'` | none (form view has none) | 0 | 5 · 5 (1) · 5 · 0 · 0 | | form `submitBehavior` | `ObjectForm.tsx:1312-1370`, `:1926-1928`; `WizardForm.tsx:1005-1065`; `submitRedirect.ts:202` | `kind`; `url`, `delayMs`; `title`, `message` | `FormViewSchema.submitBehavior` | 3 (3) | 49 · 36 (20) · 32 · 4 · 13 | | form `navigateOnSuccess` | `successBehavior.ts:118-131` via `ObjectForm.tsx:1373`, `:1412-1429`; `WizardForm.tsx:1071`, `:1085-1092` | a string template | none | 0 | 5 · 5 (1) · 5 · 0 · 0 | | form `mobile` | `ObjectForm.tsx:1857-1942` | `stickyActions`, `stepper`, `stepperMinFields`, `stepperFieldsPerStep`, `fullscreenLongText`; presence (`data-mobile-form`) | none | 0 | 14 · 14 (8) · 14 · 0 · 0 | | form `fields` (held) | `ObjectForm.tsx:961-981` (pool), `:323` (field security), `:387` (master-detail hand-off), `:1665-1683` (section intersection); `flatFields.ts:71-79` (drawer / modal, `DrawerForm.tsx:473`, `:877`, `ModalForm.tsx:562`, `:1037`); `sectionFields.ts:203` (the warning) | a string, or an entry's `name` | none; objectui declares `string[]` | 0 | 33 · 25 (20) · 17 · 8 · 8 — candidate `z.array(z.string())` | | form `sections` (held) | `ObjectForm.tsx:244-314` (`group`, `sectionGroups.ts:139`), `:288-298`, `:324-327`, `:364-367`, `:386`, `:406-608` (tabbed / wizard / split / drawer / modal maps), `:1511-1742` (simple); `sectionFields.ts:319-485`; `submitTarget.ts:101-135`; `TabbedForm`, `WizardForm`, `SplitForm`, `DrawerForm`, `ModalForm` | section: `name`, `label`, `description`, `columns`, `fields`, `group`, `pane`, `collapsible`, `collapsed`, `visibleWhen`; field entry: a string, a spec field keyed by `field` (and its 26 override keys), or a runtime field keyed by `name`, drawn whole | `FormViewSchema.sections` | 3 (3) | 252 · 218 (90) · 203 · 15 · 34 — candidate `FormViewSchema.shape.sections` | | form `customFields` (objectui-held) | `ObjectForm.tsx:755`, `:1180`, `:1625`, `:1675`; `customFieldsMerge.ts:59-108`; `sectionFields.ts` (`findCustomFieldMember`); `DrawerForm.tsx:433`, `:465`, `:480`; `ModalForm.tsx:523`, `:555`, `:569`; `SplitForm.tsx:323`; `TabbedForm.tsx:424`; `WizardForm.tsx:688`; `submitTarget.ts:131-135`; `index.tsx:215` | `name`, then the whole runtime field | none in the spec | 0 | 32 · 29 (17) · — · — · 3 (every static entry is a runtime field keyed by `name`) | | master-detail `fields` (held) | `MasterDetailForm.tsx:1693` into the parent form (read as the form's) | as the form's | as the form's | 4 (4) | 85 · 82 (13) · 80 · 2 · 3 — candidate `z.array(z.string())` | | master-detail `sections` (held) | `MasterDetailForm.tsx:1692` into the parent form | as the form's | as the form's | 0 | 18 · 15 (9) · 15 · 0 · 3 — candidate `FormViewSchema.shape.sections` | The objectui values were parsed through the built row on this branch (typed members) and through the candidate shape (held members). The not-static values are run-time hand-offs (`ObjectView.tsx:2585`, `AppContent.tsx:1152`, `RecordFormPage.tsx:368`, `useActionModal.tsx:254`, `ViewPreview.tsx:258`, `StudioDesignSurface.tsx:4139`, `EmbeddableForm.tsx:562`, `MasterDetailForm.tsx:1682`, the drawer / modal / form master-detail routes, the designer's `ObjectManager` / `FieldDesigner`), test-loop variables, and helper results the instrument does not evaluate. ## Writer parse results (A3) **Typed members — no refused value is one the form draws.** - `submitBehavior`: 4 static values are refused, each `{ kind: 'redirect', url: '//example.com/thanks' }` (`ObjectForm.submitRedirect.test.tsx:264`, `:276`; `WizardForm.submitRedirect.test.tsx:204`, `:220`), and each test asserts the form refuses it and navigates nowhere. Of the 13 not-static values, read by hand: 9 are relative redirects that parse (`submitRedirect.injectedNavigation.test.tsx:164`, `:176`, `:193`, `:225`, `:232`, `:275`, `:281`, `:296`, `:303`), and 4 are redirect fixtures the form refuses (the same-origin absolute `${window.location.origin}/thanks` at `ObjectForm.submitRedirect.test.tsx:233`, `WizardForm.submitRedirect.test.tsx:179`, `submitRedirect.injectedNavigation.test.tsx:208`, and `//example.com/thanks` at `:309`). All 3 objectstack values (the showcase's new-project wizard and its two copies in the lint and spec tests) parse. - `contentLayout`, `navigateOnSuccess`, `mobile`: every value parses. **Held members — the candidate shape refuses values the form draws.** - form `fields` (candidate `z.array(z.string())`), 8 refused. **Drawn — working writers:** objectui's published page-builder guide (`skills/objectui/guides/page-builder.md:263`, an `os:check` example: `fields: [{ name, label, type, required }, …]`), the field-security payload pin (`fieldSecurityPayload.test.tsx:214`, `:222`, `:230`, `{ name, label }` entries on all three containers), the system-managed payload pin (`systemManagedPayload.test.tsx:209`), and the `{ name }` row objectui pins as behaviour (`__tests__/objectFormFieldsMembers-8071.test.tsx:165-167`, "recorded as drift, not a second contract"). **Probes:** `{ field: 'note' }` and `{ field: 'sent_at' }` (`objectFormFieldsMembers-8071.test.tsx:132`, `:183`), which the form warns about and skips. - master-detail `fields`, 2 refused: `[{ name: 'note' }, 'status']` (`__tests__/topLevelFieldsWarnCoverage-8847.test.tsx:254-257`, "`{ name }` is tolerated as the same member as the bare name") is **drawn**; `{ field: 'note' }` (`:104`) is a probe of the warning. - form `sections` (candidate `FormViewSchema.shape.sections`), 15 refused. **Drawn — working writers:** objectui's README wizard example with inline runtime fields (`packages/plugin-form/README.md:764`), and its submit-target pins that assert that shape renders and submits (`submitTargetRefusal.test.tsx:345`, and `:374`, where the inline field is drawn and only the submit is refused for the bare name beside it). **Probes:** `submitTargetRefusal.test.tsx:422` (the simple arm resolves zero fields from inline sections), 8 `className` / `gridClassName` sections (`__tests__/sectionStyleKeysRetired-13626.test.tsx`, the retired reads), a section with neither `fields` nor `group` and a `group` beside a `label` / `collapsible` (`__tests__/formSectionGroupReference-7051.test.tsx:270`, `:307`, rendered as nothing and as ignored-and-reported), and a blank view-level `visibleWhen` (`wizardVisibleWhenFault-8069.test.tsx:134`, diagnosed). - master-detail `sections`: no measured value is refused; held with the form's member because it is the same read (A4). Under the triage caveat ("a narrowing that would refuse a measured writer is reported, not shipped silently") and the seat's test, this list is the report: the four held members are not narrowed, and each stays in the enumeration pin's ledger as `held-for-decision` with the read it rests on. The renderer side is the seat's to card. ## A2, per member - **Typed by reference (1):** `submitBehavior` → `FormViewSchema.shape.submitBehavior` (the discriminated union on `kind`, its four strict arms and the redirect `url` rule). It states the member and accepts every drawn value. - **Typed to the renderer's read (3):** `contentLayout` (`'simple' | 'tabbed'`), `navigateOnSuccess` (`z.string()`), `mobile` (the five members objectui's own `ObjectFormSchema.mobile` declares; the two counts positive integers — the read clamps `stepperFieldsPerStep` below 1 to 1, and no writer authors a count below 1). - **Held (4):** form `fields` (no by-reference schema; the renderer's declared type `string[]` refuses the `{ name }` entry it draws), form `sections` (`FormViewSchema.sections` states every section key the form reads but refuses the shape-3 field entry it draws), master-detail `fields` and `sections`. - **Recorded with a reason, not typed (1):** `customFields` — its entries are objectui's runtime form field (`FormField`, identity key `name`, about forty members drawn whole). The spec declares no such field, and its own `FormFieldSchema` is keyed by `field`, which the merge never matches. Typing it means declaring that contract in the spec first, which is the `objectui-held` stage's definition, so its ledger line moves there (the `objectui-held` text now names `FormField`). See the open question in the dev report. - **Runner-forwarded:** none. ## A4: one read, two rows `MasterDetailForm` builds its parent form with `sections: schema.sections, fields: schema.fields` (`MasterDetailForm.tsx:1692-1693`), so each master-detail member is read by exactly the form's read. Both rows take one disposition per member: held. The master-detail `fields` hold also rests on its own drawn `{ name }` writer; the master-detail `sections` hold rests on the shared read — typing it alone would give one read two accept sets. ## The pin (A5) - `component-props-unknown-members.pin.test.ts`: four `staged('object-form', …)` lines leave (`contentLayout`, `submitBehavior`, `navigateOnSuccess`, `mobile`); form `fields[]` and `sections[]` and master-detail `sections[]` / `fields[]` become `held-for-decision`, each with its read and writers in the comment; `customFields` becomes `objectui-held`. The `object-form` stage leaves `STAGES` with no line using it. The `held-for-decision` text now reads "a typed shape exists (by reference, or the renderer's own declared type)", because form `fields` has no by-reference schema. - `component-form-family-typed-members.pin.test.ts`, the companion pin in stage 2's pattern: §1 every shape a measured writer authors parses byte-identical (16 cases, plus absence); §2 17 refusals by code and path, the `heading` → `title` prescription, and a lit control; §3 `submitBehavior`'s def identity with the form view's, and the measured vocabularies of `contentLayout` and `mobile`; §4 the D3 registration. - Red then green, and the ablation: see "Gates". ## The rest of the kit (A6) - ADR-0087 D3 entry `ui-object-form-members-typed` (D3 only, no conversion, reason recorded) and its step-18 rationale fragment at order 67 (one more than the highest, 66). - `dropped-refinements.baseline.json` gains `ui/ObjectFormProps` → `submitBehavior.options[1].url`: the form view's redirect `url` refinement reaches this second published schema by reference and does not project into JSON Schema; the build refused until the line was added, as #21445's by-reference `bulkActionDefs` did. - Regenerated by `check:generated --fix` (only what it proved stale): the step-18 semantic region of `migrations/registry.ts`, `content/docs/references/ui/component.mdx`, and the `ui/` strictness counts (191 → 192 sites, +1 strict for the `mobile` block). - Changeset: `@objectstack/spec` `minor`, a **BREAKING** banner, `Clause-②: yes (narrowing)`, a FROM → TO table, the measured census, and the ADR-0087 marker `registered`. ## objectui fixtures (A7) None needs respelling at objectui's next `@objectstack/spec` bump. The 8 refused `submitBehavior` values are render-only tests that mount `ObjectForm` / `WizardForm` directly; none parses through `ObjectFormBlockSchema`. objectui's parse-through tests (`types/src/__tests__/object-form-properties-bag-10859-b4.test.ts`, `p1-spec-alignment.test.ts`) author only values that parse, or members this PR does not narrow. objectui's source indexes `SpecObjectFormProps['layout']` only, which is not narrowed. ## Gates All readings are at `ee6a556ad8`, the fourth merge of `origin/main` (`cc645f2385`, docs only), unless one says otherwise. Every exit code was written to disk before any pipe. This run resumed after a container restart at `f63c7849b6`: the third gate round there had recorded 87 of 114 exit codes when it was cut off, so after the fourth merge every gate, build, test and the ablation below re-ran at `ee6a556ad8`. The branch's own added and removed lines are byte-identical to the second round's (only context lines moved). After the fourth merge, `main` advanced by `a7ab047cf6` and `901e7cf13a` (`packages/rest`, `packages/cloud-connection`, the dogfood suite and two changesets): none of this branch's 9 paths and nothing under `packages/spec`. They are not merged here, so every reading below stays at the head it cites; the merge queue rebuilds onto current `main`. - **Derived set:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 114 commands (9 paths, 549 changed lines, +521 / -28, merge base `cc645f238`), all run; `--ran`: **114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN**, every one exit 0. - **`@objectstack/spec`:** `build` exit 0 (the package's own build, after a turbo run that replayed 72 of 72 tasks from cache), and the tree stayed clean; `check:generated` exit 0 — "All 15 generated artifacts are up to date"; `check:migration-registry` — "src/migrations/registry.ts is current (357 semantic, 246 retired-key, 218 retired-def)"; `check:liveness` — "packages/spec/liveness/state-counts/ is current"; `check:strictness-ledger` — "463 triaged site(s) measured"; `check:authorable-surface` (the base trails its anchor, an information line); `check:api-surface` — "public API surface + factory signatures unchanged"; `check:docs` — "226 generated files in sync with packages/spec": each exit 0. `check:generated` and `check:api-surface` re-ran after the package build, exit 0. - **Tests and types:** `pnpm --filter @objectstack/spec test` — **Test Files 607 passed (607), Tests 17992 passed, 1 todo**, exit 0; `typecheck` exit 0 with "check:test-typecheck: OK"; `pnpm --filter @objectstack/lint test` (the import side of `ComponentPropsMap`) — **119 files, 5620 tests passed**, exit 0; the three `ComponentPropsMap` pins — **3 files, 139 tests passed**. - `pnpm check:doc-authoring` and `pnpm check:nul-bytes` ("scanned 9949 text file(s) … no raw ASCII control bytes"): exit 0. - **`check-widening-tells`** on the branch diff: `--declaration no` exit 4 with six tells. Five are T1 tells at the new `mobile` block's keys inside the former `z.unknown()` member — the shape the gate's own text rules a true refusal, so declare `yes`. The sixth is a T2 at `component.zod.ts:41`, the `FormViewSchema` specifier in the multi-line import list from `./view.zod`, read as a new member of a closed set; it is a false tell (an import specifier widens no accept set). `--declaration yes` exit 0. - **Changeset gates, with this body as the `--event` payload:** `check-changeset-no-major` exit 0 — "LEVEL AXIS: this PR declares clause-② `yes (narrowing)`, and no package whose `packages/**/src/**` it moves is graded `patch`" · "direction arm: `narrowing` — a BREAKING change; during the launch window it ships `minor`"; `check-adr-0087-registration` exit 0 — "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition" (`registered ui-object-form-members-typed`); `check-empty-changeset` exit 0 — "No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)". - **Red then green, and the ablation** (through `node scripts/ablation-replace.mjs` in wrap mode, inside a script with its own `EXIT INT TERM` restore trap): `object-form` `contentLayout` reverted to `z.unknown().optional()`, with no ledger line. It landed: anchor x1 to x0, replacement x0 to x1, blob `2594178cfc` to `82b2f86644`. Red: **Tests 5 failed | 86 passed (91)** — the enumeration pin's §1 received exactly `[ 'object-form contentLayout' ]` and its census-equals-ledger control read 96 against 95, and the companion pin failed its two `contentLayout` refusals and its vocabulary case. Restored: blob after restore `2594178cfc` == blob at HEAD, `git diff HEAD` empty (the tool's check and the trap's both). Green rerun: **Tests 91 passed (91)**. The pins import `./component.zod` from source, so no build sits between mutation and run. - **Lint, narrowed:** eslint over the 5 changed TypeScript files (each resolves a config by `--print-config`; the other 4 changed files resolve none), `--no-inline-config --format json`: 5 files, 0 errors, 0 warnings, exit 0. `eslint.config.mjs` never enables type-aware linting (lines 326-328), so this diff cannot move the verdict on an untouched file. - **NOT MEASURED:** the Console Pin Gate, Dogfood and the full `pnpm lint`; reason: CI-owned. ## Acceptance notes - **The holds.** Form `fields`: objectui's declared type and its zod twin are `string[]`, its registration calls `{ name }` "tolerated", and its own pin calls it "drift, not a second contract" — the arguments for a ruling, not a licence to ship past the caveat. Form `sections`: the shape-3 entry is how objectui's sectioned variants run with no data source (`submitTarget.ts:36-61`), shipped in its README. Both are the seat's to card on the renderer side; this PR changes neither accept set. - **For the `sections` ruling, observed:** the form view's section accepts the deprecated `visibleOn` and string `columns` (`'2'`), which the form view's own parse normalizes. A page component's `properties` is never parsed, so on this door the renderer would read them raw: no arm reads a section `visibleOn`, and the simple arm's column clamp takes numbers only (`ObjectForm.tsx:1599-1600`). Typing `sections` by reference would accept both on a door where they render nothing. - **objectui's page-builder guide** (`skills/objectui/guides/page-builder.md:263`) teaches `object-form` `fields` entries with `label`, `type` and `required`; the form draws each entry by `name` and drops the other three. It belongs with the `fields` hold's ruling; noted, not filed here. - No objectui edit and no renderer change. #21464 remains open for S-metric and S-objectui-held. object-kanban `conditionalFormatting` stays held on objectstack-ai/objectui#11522, object-grid `columns` on objectstack-ai/objectui#11544, and the four form members here await the seat's carrier. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 901e7cf commit 958cfe2

9 files changed

Lines changed: 521 additions & 28 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: four members of an `object-form` page block take the shape the form reads instead of any value — `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` (#21464)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered ui-object-form-members-typed -->
10+
11+
**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the row: 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+
- **Four members are typed.** `ComponentPropsMap['object-form']` declared `contentLayout`, `submitBehavior`, `navigateOnSuccess` and `mobile` as `z.unknown()`, although the form reads each with one shape. Any value passed, and an off-shape one was answered with a silent default: a `submitBehavior` whose `kind` the form does not know showed the thank-you panel; a misspelled `contentLayout` stacked the modal's sections; a `navigateOnSuccess` that is not a string failed the submit after the record had been written; a misspelled `mobile` member was ignored.
16+
- **`submitBehavior` is the form view's own block, by reference** — `{ kind: 'thank-you', title?, message? }`, `{ kind: 'redirect', url, delayMs? }`, `{ kind: 'continue' }` or `{ kind: 'next-record' }` — with the same rule on a `redirect` `url` a form view carries: a relative path, interpolating declared record fields as `{{record.field_name}}`.
17+
- **The measured shape, where no form view declares the member:** `contentLayout` is `'simple'` or `'tabbed'`; `navigateOnSuccess` is a relative path string (`{id}` / `{recordId}` interpolate the saved record's id); `mobile` is `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `'auto'` and the two counts positive integers.
18+
- **`ObjectFormProps`** carries these types on the four members instead of `unknown`.
19+
- **The form's `fields` and `sections`, and the master-detail form's `fields` and `sections`, are not narrowed** and still accept any value. The form draws a top-level `fields` entry written as `{ name }` by that name, and it draws an inline runtime field (`{ name, type, … }`) written inside a section's `fields` as it stands — two shapes the typed members (field-name strings; the form view's section, whose field entry is keyed by `field`) would refuse. Each is held until that read is ruled. The master-detail form hands both members to its form unchanged, so they are held with the form's.
20+
- **`customFields` is not narrowed either.** Its entries are the console's runtime form field (keyed by `name`), which the spec has not declared; it is typed once the spec declares it.
21+
22+
## FROM → TO
23+
24+
| you wrote on an `object-form` | write instead |
25+
|:--|:--|
26+
| `submitBehavior: 'thank-you'` | `submitBehavior: { kind: 'thank-you' }` |
27+
| `submitBehavior: { kind: 'toast' }` (any `kind` outside the four) | one of `thank-you`, `redirect`, `continue`, `next-record` |
28+
| `submitBehavior: { kind: 'thank-you', heading: 'Done' }` | `{ kind: 'thank-you', title: 'Done' }` |
29+
| `submitBehavior: { kind: 'redirect', url: 'https://app.example.com/done' }` | a relative path: `url: '/done'` |
30+
| `contentLayout: 'tabs'` | `contentLayout: 'tabbed'` |
31+
| `navigateOnSuccess: { url: '/orders/{id}' }` | `navigateOnSuccess: '/orders/{id}'`, or `submitBehavior: { kind: 'redirect', url: '/orders/{{record.id}}' }` |
32+
| `mobile: { stepper: 'yes' }` | `mobile: { stepper: true }`, or `'auto'` for phone-width viewports only |
33+
| `mobile: { stepperFieldsPerStep: 0 }` | delete the key (one field a step is the default), or a positive integer |
34+
35+
The one-line fix: write each member as the table above shows. No conversion is registered, because an off-shape value has no rewrite that both keeps what the form shows today and honours what the author wrote; the D3 entry `ui-object-form-members-typed` carries that judgment.
36+
37+
## Who is affected, measured
38+
39+
A writer is a page-component node: an object literal naming the type, a literal annotated with the block's type, a `schema={{…}}` on the block's React component, a call into a local helper that builds the node, or a direct parse through the row. Each member's value is read through same-file constants and local helpers. The control is `objectName` on the same nodes.
40+
41+
- **objectstack** at `e909aa0a23`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/` and `apps/`: 16 `object-form` nodes (the control on 13). Three values among the four members: the showcase's new-project wizard `submitBehavior` (a thank-you panel) and two copies of it in the lint and spec tests. All three parse.
42+
- **objectui** at the `.objectui-sha` pin `89cad75d55`: 539 `object-form` nodes (the control on 522). Across the four members there are 73 values: 60 are static, and 56 of them parse. The 4 that do not are test fixtures of a protocol-relative redirect (`//example.com/thanks`), each asserting that the form refuses it and navigates nowhere. Of the 13 values that are not static, 9 are relative redirects that parse by inspection, and 4 are redirect fixtures the form refuses (three same-origin absolute URLs and one protocol-relative one). No refused value is one the form draws.
43+
- **Deployed metadata** was not measured.

‎content/docs/references/ui/component.mdx‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ Sort field and direction pair
539539
| **drawerWidth** | `string \| number` | optional | Drawer width (drawer) |
540540
| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal) |
541541
| **modalCloseButton** | `boolean` | optional | Show the modal close button (modal) |
542-
| **contentLayout** | `any` | optional | Modal content layout config (modal) |
542+
| **contentLayout** | `Enum<'simple' \| 'tabbed'>` | optional | How the modal presentation lays out its sections (modal) — 'simple' stacks them (the default); 'tabbed' puts each section on its own tab once more than one section has a field to show |
543543
| **confirmOnDiscard** | `boolean` | optional | Confirm before discarding edits (drawer/modal) |
544544
| **submitText** | `string \| Record<string, string>` | optional | Submit button label |
545545
| **cancelText** | `string \| Record<string, string>` | optional | Cancel button label |
@@ -548,14 +548,32 @@ Sort field and direction pair
548548
| **showSubmit** | `boolean` | optional | Show the submit button |
549549
| **showCancel** | `boolean` | optional | Show the cancel button |
550550
| **showReset** | `boolean` | optional | Show the reset button |
551-
| **submitBehavior** | `any` | optional | What happens after a successful submit (`{ kind: 'thank-you' \| …, title?, message? }`) |
551+
| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | What happens after a successful submit — the same block a form view's `submitBehavior` declares: `{ kind: 'thank-you', title?, message? }`, `{ kind: 'redirect', url, delayMs? }` (a relative `url`, interpolating declared record fields as `{{record.field_name}}`), `{ kind: 'continue' }` or `{ kind: 'next-record' }`. Takes precedence over `navigateOnSuccess` and `resetOnSuccess` |
552552
| **successMessage** | `string \| Record<string, string>` | optional | Toast message on successful submit |
553553
| **resetOnSuccess** | `boolean` | optional | Reset the form after a successful submit |
554-
| **navigateOnSuccess** | `any` | optional | Navigate after a successful submit |
554+
| **navigateOnSuccess** | `string` | optional | Relative path to navigate to after a successful create/update — `{id}` / `{recordId}` are replaced with the saved record's id, URL-escaped; an absolute URL is refused at submit and reported on the success toast. Ignored when `submitBehavior` is set — prefer `submitBehavior` |
555555
| **readOnly** | `boolean` | optional | Render every field read-only |
556556
| **initialValues** | `Record<string, any>` | optional | Prefill values (create mode) |
557557
| **initialData** | `Record<string, any>` | optional | Alternate spelling of `initialValues` the renderer also reads |
558-
| **mobile** | `any` | optional | Mobile presentation overrides |
558+
| **mobile** | `{ stickyActions?: boolean; stepper?: boolean \| 'auto'; stepperMinFields?: integer; stepperFieldsPerStep?: integer; … }` | optional | Phone presentation options, each opt-in — `{ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }`. Read by the flat (simple, sectionless) form |
559+
560+
### Nested Shape: `ObjectFormProps.submitBehavior[kind='redirect']`
561+
562+
| Property | Type | Required | Description |
563+
| :--- | :--- | :--- | :--- |
564+
| **kind** | `'redirect'` | ✅ | |
565+
| **url** | `string` | ✅ | Where the browser goes after a successful submit. Ruled 2026-08-11: (1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative URLs are refused, which is what closes the open-redirect face; (2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, and every interpolated value is URL-escaped when the redirect is built; (3) a verbatim redirect on the resolved relative path is the intended consumption. To send the browser OUT of the app, use an app navigation item (`{ type: 'url', url }`) instead. |
566+
| **delayMs** | `integer` | optional | |
567+
568+
### Nested Shape: `ObjectFormProps.mobile`
569+
570+
| Property | Type | Required | Description |
571+
| :--- | :--- | :--- | :--- |
572+
| **stickyActions** | `boolean` | optional | Pin the submit / cancel bar to the bottom of a phone-width viewport |
573+
| **stepper** | `boolean \| 'auto'` | optional | One-step-at-a-time wizard for a flat form — `true` always, `'auto'` only on a phone-width viewport once the form has `stepperMinFields` fields, `false` (the default) never |
574+
| **stepperMinFields** | `integer` | optional | The field count at which `stepper: 'auto'` steps up (default 8) |
575+
| **stepperFieldsPerStep** | `integer` | optional | Fields shown per stepper step (default 1) |
576+
| **fullscreenLongText** | `boolean` | optional | Offer a fullscreen editor on textarea and rich-text fields |
559577

560578

561579
---

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 191 | 180 | 4 | 0 | 7 |
24+
| `ui/` | 192 | 181 | 4 | 0 | 7 |
2525

2626
## `ui/` — sites
2727

@@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
3636
| `app.zod.ts` | 19 |
3737
| `bulk-action.zod.ts` | 4 |
3838
| `chart.zod.ts` | 8 |
39-
| `component.zod.ts` | 61 |
39+
| `component.zod.ts` | 62 |
4040
| `dashboard.zod.ts` | 11 |
4141
| `dataset.zod.ts` | 4 |
4242
| `i18n.zod.ts` | 1 |
@@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
4646
| `sharing.zod.ts` | 1 |
4747
| `view.zod.ts` | 60 |
4848
| `widget.zod.ts` | 1 |
49-
| **total** | **191** |
49+
| **total** | **192** |
5050

5151
## `ui/` — open
5252

5353
Per file, how many of its sites still silently discard unknown keys. The `Class`
5454
column that decides the bucket split is hand-written in the ledger; the arithmetic
5555
over it is here.
5656

57-
**7 strip of 191**, in 4 file(s).
57+
**7 strip of 192**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
6363
| `view.zod.ts` | 4 | 60 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **7** | **191** |
65+
| **total** | **7** | **192** |
6666

6767
| Bucket | Sites |
6868
|---|---|

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 217,
6-
"droppedRefinementSites": 653,
5+
"publishedSchemasWithDroppedRefinements": 218,
6+
"droppedRefinementSites": 654,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -1364,6 +1364,11 @@
13641364
"filter.element"
13651365
]
13661366
},
1367+
"ui/ObjectFormProps": {
1368+
"sites": [
1369+
"submitBehavior.options[1].url"
1370+
]
1371+
},
13671372
"ui/ObjectGanttProps": {
13681373
"sites": [
13691374
"filter.element",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #21464 — four members of the `object-form` page block were `z.unknown()`
6+
// although the form reads each with a fixed shape, so an off-shape value passed
7+
// the component-props gate and the form fell back or ignored it in silence. The
8+
// row now takes the form view's own `submitBehavior` by reference and the
9+
// measured shape for `contentLayout`, `navigateOnSuccess` and `mobile`. The
10+
// form's `fields` and `sections` and the master-detail form's two are held at
11+
// `z.unknown()` (the form draws a `{ name }` field entry and an inline runtime
12+
// field inside a section, which the typed shapes would refuse), and
13+
// `customFields` waits for the spec to declare objectui's runtime form field.
14+
// D3 only: page-component `properties` is not parsed on the metadata save or
15+
// load path, so a stored page is never refused; an off-shape value has no
16+
// rewrite that says what the author meant; and the authored census found no
17+
// authored value to respell — the refused values are fixtures probing that the
18+
// form refuses them.
19+
export const entry: SemanticMigration = {
20+
id: 'ui-object-form-members-typed',
21+
surface: 'page `object-form` components — `properties.contentLayout`, `.submitBehavior`, '
22+
+ '`.navigateOnSuccess` and `.mobile` (which used to accept any value)',
23+
replacement: 'the shape the form reads: `contentLayout` `\'simple\'` or `\'tabbed\'`; `submitBehavior` the '
24+
+ 'form view\'s own block — `{ kind: \'thank-you\', title?, message? }`, `{ kind: \'redirect\', url, '
25+
+ 'delayMs? }` with a relative `url`, `{ kind: \'continue\' }` or `{ kind: \'next-record\' }`; '
26+
+ '`navigateOnSuccess` a relative path string; `mobile` `{ stickyActions?, stepper?, stepperMinFields?, '
27+
+ 'stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `\'auto\'` and the two '
28+
+ 'counts positive integers. Write a `submitBehavior` `kind` as one of the four; move a `redirect` '
29+
+ 'destination to a relative path; write `heading` as `title`.',
30+
reason: 'The form reads these members with one shape, and the page-component row declared them '
31+
+ '`z.unknown()`, so any value passed the component-props gate and the form answered an off-shape one '
32+
+ 'with a silent default: a `submitBehavior` `kind` it does not know fell through to the thank-you panel; '
33+
+ 'a misspelled `contentLayout` such as `\'tabs\'` stacked the sections; a `navigateOnSuccess` that is not a '
34+
+ 'string threw after the record was written, so the submit reported a failure; and a `mobile` member it '
35+
+ 'does not read, or a `stepper` outside `true` / `false` / `\'auto\'`, was ignored. The row now takes '
36+
+ 'the form view\'s own `submitBehavior` by reference — the block the renderers already judge a redirect '
37+
+ '`url` through — so one value is judged the same way on the form view and the block, and the measured '
38+
+ 'shape for the other three. The form\'s `fields` and `sections` and the master-detail form\'s two stay '
39+
+ 'open, because the form draws a `{ name }` field entry and an inline runtime field inside a section, '
40+
+ 'which the typed shapes would refuse; and `customFields` stays open until the spec declares the '
41+
+ 'runtime form field its entries are. It is read where every page component\'s props are: the '
42+
+ 'component-props gate reports a refused value as an advisory `component-props-invalid` / '
43+
+ '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and '
44+
+ '`objectstack lint`, and a stored page still saves and loads, because a page component\'s '
45+
+ '`properties` is not parsed on the metadata save or load path. No conversion is registered: nothing '
46+
+ 'on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the '
47+
+ 'form shows today and honours what the author wrote — which is the judgment this entry leaves to the '
48+
+ 'upgrader. Deployed metadata NOT MEASURED.',
49+
acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no '
50+
+ '`component-props-invalid` / `component-props-unknown-key` finding under the four members\' paths. '
51+
+ 'Each form that set one of them now shows it: the post-submit behaviour it names, the modal\'s tabbed '
52+
+ 'sections, the navigation after a save, and the phone presentation.',
53+
};

0 commit comments

Comments
 (0)