Skip to content

Commit 7345308

Browse files
claude[bot]claude
andauthored
feat(spec): retire the element:form element at element grain (#9249, ADR-0049) (#11244)
* feat(spec): retire the element:form element at element grain (#9249, ADR-0049) No renderer or reader anywhere — measured objectstack c684d00, objectui 76ceb1e, cloud 5f1bf23f/a11458b (thread readings). All six ElementFormProps keys are retiredKey tombstones behind the kept ComponentPropsMap row; D2 strip conversion element-form-removed under protocol 18; enum value dropped; lint COMPONENT_FIELD_SPECS entry dropped; three conversion fixtures re-seated off the retired neighbor; docs and baselines regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw * merge origin/main (os-regen artifacts taken from main; regeneration follows) * chore(spec): re-take the element-grain verdict on today's refs and regenerate The withdrawal audit found the WIP's measurement was never written down; the verdict is re-taken on today's merged refs (objectstack dd84ddd, objectui 3ece13e33 — zero element:form readers, positive controls passing; cloud stays settled per the card's two recorded readings) and the six retired-key entries, the D2 conversion comment and the changeset now cite those refs. Regenerated: migration-registry concat, authorable surface/defaults, reference docs, openapi restore. * test(dogfood): delete the stranded onSubmit cover (#9249) Retiring element:form removed the last ExpressionInputSchema member the cel-ui row's 'submit predicates' half covered; the ADR-0060 ratchet correctly reported STALE covers. Deleted rather than re-pointed, the mapping.zod.ts:expression (#5552) way; the summary drops the dead half. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7d2cc6 commit 7345308

25 files changed

Lines changed: 704 additions & 79 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): drop the `element:form` entry from `COMPONENT_FIELD_SPECS` (#9249)
6+
7+
The whole `element:form` element retired at element grain (ADR-0049 — no
8+
renderer ever shipped for it; the #9220 shape one element over), so every
9+
`ElementFormProps` key is a `retiredKey()` tombstone and no spec-conformant
10+
page carries `fields` on it. The field-binding rule's job (resolve a field
11+
NAME against the object) is not the question a retired key raises: an authored
12+
key is already reported by name with the element-retirement prescription —
13+
which names the live replacement, the object-bound `object-form` block —
14+
through the #5068 props gate, and the binding entry would only add a second
15+
finding about a key that no longer exists — the #5775/#6629 residue class the
16+
package's own `component-field-specs-liveness` gate refuses.

‎.changeset/element-form-retired.md‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): retire the `element:form` element at element grain (#9249, ADR-0049)
6+
7+
<!-- adr-0087: registered element-form-removed -->
8+
9+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
10+
launch-window convention ships it as `minor`; the migration prescription is
11+
registered under protocol major 18, where `os migrate meta` users will look).
12+
13+
`element:form` never had a renderer or reader anywhere — the #9220
14+
(`element:filter`) shape one element over, recorded by that card's own verdict
15+
sweep. Measured at retirement (objectstack `dd84ddd796`, objectui `3ece13e33`;
16+
cloud per the origin card's two recorded readings at `5f1bf23f` and `a11458b`,
17+
positive controls passing): objectui registers no renderer for it — its
18+
`renderers/basic/elements.tsx` header deferred the element to "owning plugins"
19+
that never materialized — and Studio's designer palette carries it as a
20+
no-renderer `PALETTE_EXCLUSIONS` entry that names the live replacement ("no
21+
renderer — use the object-bound `object-form` block"). The 2026-06
22+
page-liveness audit already recorded it rendering "Unknown component type". So
23+
the full form contract `ElementFormPropsSchema` declared — `object`, `fields`,
24+
`mode`, `submitLabel`, `onSubmit` (CEL), `aria` — was a capability claim
25+
nothing kept.
26+
27+
**What is refused:** every authored `element:form` key. All six are
28+
`retiredKey()` tombstones, so authoring one is a `tsc` error and a parse error
29+
carrying the prescription (fully-qualified key, why the element is dead, the
30+
replacement, the `os migrate meta` pointer), dispatched through the KEPT
31+
`ComponentPropsMap` row — deleting the row would demote the type to an
32+
unregistered custom string the #5068 props gate deliberately skips.
33+
34+
**What stays accepted:** a bare `element:form` node with empty `properties`
35+
(the migrated shape — the open `type` union accepts any string, and deleting
36+
authored page nodes is a layout decision a mechanical conversion must not
37+
make). It renders nothing, exactly as it always did.
38+
39+
The retirement kit:
40+
41+
- `retiredKey()` tombstones for all six keys at the schema
42+
(`packages/spec/src/ui/component.zod.ts`); the `PageComponentType` enum
43+
drops the value (de-advertisement — the open string arm still accepts it)
44+
- ADR-0087 registration: six retired-key entries
45+
`ui/ElementFormProps:{aria,fields,mode,object,onSubmit,submitLabel}` and the
46+
D2 conversion `element-form-removed` (protocol 18), wired into the step-18
47+
chain — `os migrate meta --from 17` strips all six keys from authored
48+
`element:form` blocks (pure lossless deletes; none ever had an effect to
49+
lose) and leaves the bare node
50+
- pin tests (`component.test.ts` — refusal pins assert the prescription per
51+
key; a positive pin parses the bare migrated node clean and asserts nothing
52+
materializes; the kept-map-row pin flips from parse to refusal)
53+
- `packages/lint`'s `COMPONENT_FIELD_SPECS` entry drops (its own changeset);
54+
the component-translation `submitLabel` describes stop naming `element:form`
55+
as carrier (the orphaned-key decision is #10926, out of this card's scope)
56+
- generated baselines/docs follow the schema (authorable surface/defaults,
57+
spec-changes, upgrade guide, reference docs)
58+
- objectui's `elements.tsx` comment and Studio palette-exclusion lines are
59+
cross-repo and already queued under objectui#4935
60+
61+
## FROM → TO
62+
63+
```ts
64+
// before — parsed green; nothing anywhere rendered it, so the page showed
65+
// "Unknown component type" where the author expected a form
66+
{
67+
type: 'element:form',
68+
properties: {
69+
object: 'lead',
70+
fields: ['name', 'email'],
71+
mode: 'create',
72+
submitLabel: 'Create Lead',
73+
},
74+
}
75+
76+
// after — use the object-bound `object-form` block (#7751): rendered,
77+
// designer-publishable, same intent (`objectName`, `fields`, `mode`,
78+
// `submitText`)
79+
{
80+
type: 'object-form',
81+
properties: {
82+
objectName: 'lead',
83+
fields: ['name', 'email'],
84+
mode: 'create',
85+
submitText: 'Create Lead',
86+
},
87+
}
88+
```

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,12 @@ const result = AIChatWindowProps.parse(data);
7777

7878
| Property | Type | Required | Description |
7979
| :--- | :--- | :--- | :--- |
80-
| **object** | `string` | ✅ | Object for the form |
81-
| **fields** | `string[]` | optional | Fields to display (defaults to all editable fields) |
82-
| **mode** | `Enum<'create' \| 'edit'>` | optional (default: `"create"`) | Form mode |
83-
| **submitLabel** | `string \| Record<string, string>` | optional | Submit button label |
84-
| **onSubmit** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Action expression on form submit (CEL) |
85-
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
80+
| **object** | `never` | optional | [REMOVED] `element:form` property `object` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
81+
| **fields** | `never` | optional | [REMOVED] `element:form` property `fields` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
82+
| **mode** | `never` | optional | [REMOVED] `element:form` property `mode` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
83+
| **submitLabel** | `never` | optional | [REMOVED] `element:form` property `submitLabel` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
84+
| **onSubmit** | `never` | optional | [REMOVED] `element:form` property `onSubmit` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
85+
| **aria** | `never` | optional | [REMOVED] `element:form` property `aria` was removed in @objectstack/spec 17 (#9249, ADR-0049) — the whole `element:form` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion — "use the object-bound `object-form` block"), so every key on this element was a capability claim nothing kept. Delete the `element:form` component and use the object-bound `object-form` block instead (#7751) — it is rendered, designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, `submitText`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
8686

8787

8888
---

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ Interface-level page configuration (Airtable parity)
105105

106106
| Property | Type | Required | Description |
107107
| :--- | :--- | :--- | :--- |
108-
| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type (Standard enum or custom string) |
108+
| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type (Standard enum or custom string) |
109109
| **id** | `string` | optional | Unique instance ID |
110110
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
111111
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
@@ -157,7 +157,6 @@ Interface-level page configuration (Airtable parity)
157157
* `element:image`
158158
* `element:divider`
159159
* `element:button`
160-
* `element:form`
161160
* `element:record_picker`
162161
* `element:text_input`
163162

‎content/docs/ui/pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ The `type` field is a union of the standard `PageComponentType` enum and any cus
186186
- **Navigation:** `app:launcher`, `nav:menu`, `nav:breadcrumb`
187187
- **Utility:** `global:search`, `global:notifications`, `user:profile`
188188
- **AI:** `ai:chat_window`, `ai:suggestion`
189-
- **Elements:** `element:text`, `element:number`, `element:image`, `element:divider`, `element:button`, `element:form`, `element:record_picker`, `element:text_input` (`element:filter` was retired in v17.x — no renderer ever shipped for it; list surfaces own their filtering via a view's `userFilters` quick-filter bar or the list toolbar's filter builder)
189+
- **Elements:** `element:text`, `element:number`, `element:image`, `element:divider`, `element:button`, `element:record_picker`, `element:text_input` (`element:filter` and `element:form` were retired in v17.x — no renderer ever shipped for either. List surfaces own their filtering via a view's `userFilters` quick-filter bar or the list toolbar's filter builder; for forms use the object-bound `object-form` block, which is rendered and designer-publishable)
190190

191191
Components may also carry `dataSource` (per-element object binding for multi-object pages), `responsive`, and `aria` configuration. Custom string types are also accepted for project-specific widgets.
192192

‎packages/lint/src/validate-page-field-bindings.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,10 @@ export const COMPONENT_FIELD_SPECS: Readonly<Record<string, ComponentFieldSpec>>
197197
// spec-conformant page carries `fields` on it, and the #5068 props gate
198198
// reports an authored one by name with the element-retirement prescription —
199199
// the same #5775/#6629 residue class as the record-picker entries below.
200-
'element:form': { props: ['fields'] },
200+
// `element:form` had the same `{ props: ['fields'] }` entry until #9249
201+
// retired that element the same way (ADR-0049, the #9220 shape one element
202+
// over); its tombstones' prescription names the live replacement, the
203+
// object-bound `object-form` block.
201204
// `labelField` is the one field-bearing prop this element declares. Its former
202205
// companions `displayField` (renamed to `labelField`, ADR-0087 D2) and
203206
// `searchFields` (deleted, ADR-0049) were retired in #5775 and are

‎packages/lint/src/validate-react-page-props.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -721,14 +721,16 @@ describe('validateReactPageProps — record:* blocks need a context this surface
721721

722722
describe('validateReactPageProps — <Block> escape hatch (#4340)', () => {
723723
it('checks the props bag by the registered type the author names', () => {
724+
// `element:number` carries the pin since #9249 retired `element:form`
725+
// (whose `fields` entry left COMPONENT_FIELD_SPECS with the element).
724726
const f = unknownFields(
725727
validateReactPageProps(
726-
propsPage(jsx('Block', `type="element:form" objectName="crm_account" fields={['nope']}`)),
728+
propsPage(jsx('Block', `type="element:number" objectName="crm_account" field="nope"`)),
727729
),
728730
);
729731
expect(f).toHaveLength(1);
730732
expect(f[0].where).toBe('page "p" › <Block>');
731-
expect(f[0].path).toBe('pages[0].source › fields[0]');
733+
expect(f[0].path).toBe('pages[0].source › field');
732734
});
733735

734736
it('skips a type with no descriptor, and a non-static type', () => {
@@ -1332,15 +1334,17 @@ describe('validateReactPageProps — unprovisioned injected anchors (#8340)', ()
13321334
// The shared table `validate-page-field-bindings` walks; on this surface
13331335
// it is reachable only by the type the author spells out, which is what
13341336
// makes the escape hatch checked rather than a hole.
1337+
// `element:number` carries the pin since #9249 retired `element:form`
1338+
// (whose `fields` entry left COMPONENT_FIELD_SPECS with the element).
13351339
const f = validateReactPageProps(
1336-
extPage(`function Page(){ return <Block type="element:form" objectName="ext_customer" fields={['owner_id']} />; }`),
1340+
extPage(`function Page(){ return <Block type="element:number" objectName="ext_customer" field="owner_id" />; }`),
13371341
);
13381342
expect(f.filter((x) => x.rule === PAGE_FIELD_UNKNOWN)).toHaveLength(0);
13391343
const warned = f.filter((x) => x.rule === PAGE_FIELD_UNPROVISIONED);
13401344
expect(warned).toHaveLength(1);
13411345
expect(warned[0].severity).toBe('warning');
13421346
expect(warned[0].where).toBe('page "p" › <Block>');
1343-
expect(warned[0].path).toBe('pages[0].source › fields[0]');
1347+
expect(warned[0].path).toBe('pages[0].source › field');
13441348
expect(warned[0].message).toContain('external object (ADR-0015)');
13451349
});
13461350
});

‎packages/lint/src/validate-react-page-props.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ function checkObjectChart(
608608
//
609609
// `REACT_FIELD_SPECS` below describes the blocks whose metadata twin lives
610610
// under different prop names — `<ListView>` (twin: a list page's
611-
// `interfaceConfig`) and `<ObjectForm>` (twin: `element:form` + the
611+
// `interfaceConfig`) and `<ObjectForm>` (twin: the `object-form` block + the
612612
// form-layout rule) — plus `<ObjectChart>`'s `filter`.
613613
//
614614
// `COMPONENT_FIELD_SPECS`, the table `validate-page-field-bindings` walks on
@@ -883,9 +883,9 @@ function checkBlockFieldProps(
883883
);
884884
}
885885

886-
// `<Block type="element:form">` renders the registered component the author
887-
// names; every other block's type is fixed by its tag. A `record:*` type
888-
// never arrives here — `recordContextFinding` rejected it upstream.
886+
// `<Block type="element:number">` renders the registered component the
887+
// author names; every other block's type is fixed by its tag. A `record:*`
888+
// type never arrives here — `recordContextFinding` rejected it upstream.
889889
const schemaType = tag === 'Block' ? strOf(values.get('type')) : SCHEMA_TYPE_BY_TAG.get(tag);
890890
if (schemaType && COMPONENT_FIELD_SPECS[schemaType]) {
891891
out.push(

‎packages/qa/dogfood/test/expression-conformance.ledger.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
128128
},
129129
{
130130
id: 'cel-ui',
131-
summary: 'UI visibility / routing / submit predicates',
131+
// "+ submit predicates" left this summary with #9249: `element:form.onSubmit`
132+
// was the one submit predicate here, and the whole element retired.
133+
summary: 'UI visibility / routing predicates',
132134
dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log',
133135
enforcement: 'console (objectui) SchemaRenderer + server celEngine (interpret)',
134136
covers: [
@@ -148,7 +150,12 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
148150
'ui/view.zod.ts:condition',
149151
'ui/view.zod.ts:visibleWhen',
150152
'ui/view.zod.ts:visibleOn',
151-
'ui/component.zod.ts:onSubmit',
153+
// `ui/component.zod.ts:onSubmit` (element:form's submit CEL) sat here
154+
// until #9249 retired the whole `element:form` element under ADR-0049
155+
// (no renderer ever shipped, so nothing ever evaluated the predicate).
156+
// The key is a retiredKey() tombstone now — no ExpressionInputSchema
157+
// member left in source — so the cover is deleted rather than
158+
// re-pointed, the mapping.zod.ts:expression (#5552) way.
152159
// Conditional tabs (framework#2606): `page:tabs` item-level visibility.
153160
// Canonical ADR-0089 `visibleWhen` from day one (no deprecated alias on
154161
// this new surface). Interpreted by the objectui page:tabs renderer to

‎packages/spec/authorable-defaults/ui.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
"ui/ElementButtonProps:iconPosition = \"left\"",
3535
"ui/ElementButtonProps:size = \"medium\"",
3636
"ui/ElementButtonProps:variant = \"primary\"",
37-
"ui/ElementFormProps:mode = \"create\"",
3837
"ui/ElementImageProps:fit = \"cover\"",
3938
"ui/ElementMetadataViewerProps:detail = \"business\"",
4039
"ui/ElementTextInputProps:disabled = false",

0 commit comments

Comments
 (0)