From e4204118b1f4253a5ace93b64be666c5db8a2a1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 02:28:14 +0000 Subject: [PATCH] feat(spec): retire inert targetVariable on element:text_input / element:record_picker (#9198, ADR-0049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declarative hint with zero readers in any repo — the live binding resolves from the page variable whose source names the component id (PageVariableSchema). Tombstoned with retiredKey(), registered under protocol 18 (retired-key entries + D2 conversion element-input-target-variable-removed wired into step 18), pin tests, regenerated authorable-surface/docs, minor changeset per the post-17.0.0 launch-window convention. --- .../element-input-target-variable-retired.md | 68 +++++++++ content/docs/references/ui/component.mdx | 4 +- packages/spec/authorable-surface/ui.json | 4 +- packages/spec/src/conversions/registry.ts | 142 +++++++++++++++++- ...lementRecordPickerProps__targetVariable.ts | 21 +++ ...__ElementTextInputProps__targetVariable.ts | 25 +++ packages/spec/src/migrations/registry.ts | 58 ++++++- packages/spec/src/ui/component.test.ts | 26 +++- packages/spec/src/ui/component.zod.ts | 36 ++++- packages/spec/src/ui/page.test.ts | 6 +- 10 files changed, 374 insertions(+), 16 deletions(-) create mode 100644 .changeset/element-input-target-variable-retired.md create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__targetVariable.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementTextInputProps__targetVariable.ts diff --git a/.changeset/element-input-target-variable-retired.md b/.changeset/element-input-target-variable-retired.md new file mode 100644 index 0000000000..538481c04e --- /dev/null +++ b/.changeset/element-input-target-variable-retired.md @@ -0,0 +1,68 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): retire the inert `targetVariable` key from `element:text_input` and `element:record_picker` (#9198, ADR-0049) + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`; the migration prescription is +registered under protocol major 18, where `os migrate meta` users will look). + +`targetVariable` on the two SDUI input elements was a declarative hint with +zero readers in any repo — its own describe text said the live binding +"resolves via the variable whose `source` equals this component id" +(`PageVariableSchema`), and that reverse lookup +(`usePageVariableBinding(schema?.id)` in the console renderer) is the only +binding mechanism that exists. Measured (objectstack-ai/objectui#3834, +re-verified at retirement): no renderer, hook or runtime in objectui, +framework or cloud reads the key. An author — human or AI — who read the +manifest, wrote `targetVariable`, and skipped the variable's `source` got an +input that wrote nothing, with a success receipt and no diagnostic anywhere. +Same disposition as the sibling inert hint settled by retirement in objectui +PR #4794. + +**What is refused:** an authored `targetVariable` on `element:text_input` or +`element:record_picker` properties. Both keys are `retiredKey()` tombstones — +refused at `tsc` (typed `never`) and at the parse, message carrying the +prescription. + +**What stays accepted:** every text input / record picker without the key, +byte-identically — including the working binding (`variables[].source`), which +is untouched. `targetVariable` on `element:filter` is a different surface and +is not part of this disposition. Runtime behaviour is unchanged: nothing ever +read the key, so removing it removes no behaviour. + +The retirement kit: + +- tombstones at the schema (`packages/spec/src/ui/component.zod.ts`) +- ADR-0087 registration: retired-key entries + `ui/ElementTextInputProps:targetVariable` + + `ui/ElementRecordPickerProps:targetVariable` and the D2 conversion + `element-input-target-variable-removed` (protocol 18), wired into the step-18 + chain — `os migrate meta --from 17` strips the key from old sources (pure + lossless delete; it never had an effect to lose) +- pin tests (`component.test.ts` — refusal carries the prescription; clean + parses materialize nothing) +- generated baselines/docs follow the schema (`authorable-surface/`, + `json-schema.manifest/`, spec-changes, upgrade guide, reference docs) + +## FROM → TO + +```ts +// before — parsed green; the hint bound nothing +{ + id: 'email_input', + type: 'element:text_input', + properties: { inputType: 'email', targetVariable: 'contact_email' }, +} + +// after — delete the key; declare the binding on the page variable instead +{ + id: 'email_input', + type: 'element:text_input', + properties: { inputType: 'email' }, +} +// page.variables: [{ name: 'contact_email', type: 'string', source: 'email_input' }] +``` + + diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 409c336f4a..6f0a12ecf4 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -149,7 +149,7 @@ const result = AIChatWindowProps.parse(data); | **filter** | `any` | optional | Filter criteria for available records | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order — synonym of the component-level `dataSource.sort`, which takes precedence when both are set | | **limit** | `integer` | optional | Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50) | -| **targetVariable** | `string` | optional | Page variable to bind selected record ID(s) | +| **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to rewrite existing sources automatically. | | **placeholder** | `string \| Record` | optional | Placeholder text | | **emptyText** | `string \| Record` | optional | Text shown when the query returns no records (default "No records") | | **displayField** | `never` | optional | [REMOVED] `element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a required declaration no renderer ever read, while the renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key to `labelField`; the value (a field name) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. | @@ -173,7 +173,7 @@ const result = AIChatWindowProps.parse(data); | **required** | `boolean` | optional (default: `false`) | Mark the field as required | | **disabled** | `boolean` | optional (default: `false`) | Disable the input | | **description** | `string \| Record` | optional | Helper text shown below the input | -| **targetVariable** | `string` | optional | Page variable this input writes to. Declarative hint; the live binding resolves via the variable whose `source` equals this component id (see PageVariableSchema). | +| **targetVariable** | `never` | optional | [REMOVED] `element:text_input` property `targetVariable` was removed in @objectstack/spec 17 (#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the typed value, declare it on the variable — `variables: [{ name: '', type: 'string', source: '' }]`. Run `os migrate meta --from 17` to rewrite existing sources automatically. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index df1d56250f..99cb6c991a 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -422,7 +422,7 @@ "ui/ElementRecordPickerProps:placeholder", "ui/ElementRecordPickerProps:searchFields [RETIRED]", "ui/ElementRecordPickerProps:sort", - "ui/ElementRecordPickerProps:targetVariable", + "ui/ElementRecordPickerProps:targetVariable [RETIRED]", "ui/ElementRecordPickerProps:valueField", "ui/ElementTextInputProps:aria", "ui/ElementTextInputProps:defaultValue", @@ -432,7 +432,7 @@ "ui/ElementTextInputProps:label", "ui/ElementTextInputProps:placeholder", "ui/ElementTextInputProps:required", - "ui/ElementTextInputProps:targetVariable", + "ui/ElementTextInputProps:targetVariable [RETIRED]", "ui/ElementTextProps:align", "ui/ElementTextProps:aria", "ui/ElementTextProps:content", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 3db9844030..7118f545db 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -6731,6 +6731,146 @@ const recordChatterPositionVocabulary: MetadataConversion = { }, }; +/** + * `element:text_input.targetVariable` / `element:record_picker.targetVariable` + * — a declarative hint with zero readers (protocol 18, #9198, ADR-0049). + * + * The key's own describe text admitted the split: "Declarative hint; the live + * binding resolves via the variable whose `source` equals this component id". + * Measured (objectstack-ai/objectui#3834, re-verified at retirement time): + * no renderer, hook or runtime in objectui, framework or cloud reads it — the + * console binds input elements through `usePageVariableBinding(schema?.id)`, + * a reverse lookup over `PageVariableSchema.source`. So an author (human or + * AI) who read the manifest, wrote `targetVariable` and skipped the + * variable's `source` got an input that wrote nothing, with a success receipt + * and no diagnostic anywhere — the ADR-0078 shape, on the exact surface AI + * authors write from. Enforce-or-remove: removed, not deprecated; the same + * disposition its sibling inert hint reached in objectui PR #4794. + * + * Pure lossless deletes — the key never had an effect to lose. The live + * binding (`variables[].source`) is untouched, and the tombstone's + * prescription tells the author how to declare it. + */ +const elementInputTargetVariableRemoved: MetadataConversion = { + id: 'element-input-target-variable-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: + 'page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable', + summary: + "text-input/record-picker component prop 'targetVariable' removed (#9198 — a declarative " + + 'hint nothing read; the live binding resolves from the page variable whose `source` names ' + + 'the component id)', + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'element:text_input' && component.type !== RECORD_PICKER_COMPONENT_TYPE) { + return component; + } + const properties = component.properties; + if (!isDict(properties)) return component; + const stripped = stripKeys(properties, ['targetVariable'], emit, `${path}.properties`); + if (stripped === properties) return component; + return { ...component, properties: stripped }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'contact_capture', + variables: [ + // The LIVE half of the binding — the variable's `source` names the + // component id, and nothing here touches it. + { name: 'contact_email', type: 'string', source: 'email_input' }, + ], + regions: [ + { + name: 'main', + components: [ + { + id: 'email_input', + type: 'element:text_input', + properties: { inputType: 'email', targetVariable: 'contact_email' }, + }, + { type: 'element:record_picker', properties: { object: 'showcase_project', targetVariable: 'selected_id' } }, + // `targetVariable` on `element:filter` is a DIFFERENT surface, + // outside #9198's disposition — untouched here. + { type: 'element:filter', properties: { object: 'a', fields: ['status'], targetVariable: 'active_filter' } }, + // Nested one container down (#6775) — the walk descends. + { + type: 'page:card', + properties: { + title: 'Pick one', + children: [ + { type: 'element:record_picker', properties: { object: 'b', targetVariable: 'picked' } }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape, on a slotted record page. + { + name: 'contact_detail', + kind: 'slotted', + regions: [], + slots: { + details: [ + { type: 'element:text_input', properties: { label: 'Note', targetVariable: 'note_draft' } }, + ], + }, + }, + ], + }, + after: { + pages: [ + { + name: 'contact_capture', + variables: [ + { name: 'contact_email', type: 'string', source: 'email_input' }, + ], + regions: [ + { + name: 'main', + components: [ + { + id: 'email_input', + type: 'element:text_input', + properties: { inputType: 'email' }, + }, + { type: 'element:record_picker', properties: { object: 'showcase_project' } }, + { type: 'element:filter', properties: { object: 'a', fields: ['status'], targetVariable: 'active_filter' } }, + { + type: 'page:card', + properties: { + title: 'Pick one', + children: [ + { type: 'element:record_picker', properties: { object: 'b' } }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'contact_detail', + kind: 'slotted', + regions: [], + slots: { + details: [ + { type: 'element:text_input', properties: { label: 'Note' } }, + ], + }, + }, + ], + }, + // One per stripped key — the filter element keeps its (out-of-scope) key. + expectedNotices: 4, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -6803,7 +6943,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly> // The prescription reaches authors through the tombstone (`tsc` + the parse) // and the D3 semantic entry `metadata-plugin-additional-types-retired`. 'kernel/MetadataPluginConfig:additionalTypes', + // #9198 — ADR-0049 enforce-or-remove. `targetVariable` on + // `element:record_picker` was a declarative hint with zero readers: the picker + // writes the selected record id through the reverse binding — the page + // variable whose `source` names this component's `id` (PageVariableSchema; + // `usePageVariableBinding(schema?.id)` in objectui's console renderer) — and + // nothing anywhere read this key. Measured (objectstack-ai/objectui#3834, + // re-verified at retirement time): zero production readers in objectui, + // framework and cloud; the only repo-wide hits were the reverse-parity gate's + // exemption block and spec's own accept tests. Same silent-no-op hazard and + // same disposition as the `element:text_input` twin registered beside this + // entry, and as the #5775 record-picker inert keys one shape over. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstone ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // Sources are rewritten by the D2 conversion + // `element-input-target-variable-removed`. + 'ui/ElementRecordPickerProps:targetVariable', + // #9198 — ADR-0049 enforce-or-remove. `targetVariable` on `element:text_input` + // was a declarative hint with zero readers: its own describe text said the + // live binding "resolves via the variable whose `source` equals this component + // id" (PageVariableSchema), and that reverse lookup + // (`usePageVariableBinding(schema?.id)` in objectui's console renderer) is the + // only binding mechanism that exists. Measured (objectstack-ai/objectui#3834, + // re-verified at retirement time): no production reader in objectui, framework + // or cloud — the only repo-wide hits were the reverse-parity gate's exemption + // block (which cites the origin card) and spec's own accept tests. An author + // who wrote `targetVariable` and skipped the variable's `source` got an input + // that wrote nothing, with a success receipt — the ADR-0078 silent-no-op + // shape, on the exact surface AI authors write from. Same disposition as its + // sibling inert hint (objectui#3829, settled by retirement in objectui + // PR #4794). + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstone ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // Sources are rewritten by the D2 conversion + // `element-input-target-variable-removed` (a page component IS a stack + // collection member, unlike the `kernel/Manifest:loading` family). + 'ui/ElementTextInputProps:targetVariable', // ], }; diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 7b2bf3821b..d78fdb9627 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -979,11 +979,9 @@ describe('Interactive Elements — element:record_picker', () => { valueField: 'id', label: 'Account', filter: { status: 'active' }, - targetVariable: 'selected_account', placeholder: 'Search accounts...', emptyText: 'No accounts', }); - expect(props.targetVariable).toBe('selected_account'); expect(props.labelField).toBe('name'); expect(props.valueField).toBe('id'); expect(props.label).toBe('Account'); @@ -1039,6 +1037,15 @@ describe('Interactive Elements — element:record_picker', () => { expect(props).not.toHaveProperty('displayField'); expect(props).not.toHaveProperty('searchFields'); expect(props).not.toHaveProperty('multiple'); + expect(props).not.toHaveProperty('targetVariable'); + }); + + // #9198 tombstone — `targetVariable` was a declarative hint with zero + // readers; the live binding is the page variable whose `source` names this + // component's `id` (ADR-0049 enforce-or-remove). + it('rejects the retired `targetVariable` with its prescription', () => { + expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', targetVariable: 'selected_id' })) + .toThrow(/`targetVariable`.*removed.*Delete the key/s); }); // ── #6276 — the flat `sort` / `limit` shorthands ───────────────────────── @@ -1140,11 +1147,22 @@ describe('Interactive Elements — element:text_input', () => { required: true, disabled: false, description: 'We never share it', - targetVariable: 'email', }); expect(props.inputType).toBe('email'); expect(props.required).toBe(true); - expect(props.targetVariable).toBe('email'); + }); + + // #9198 tombstone — `targetVariable` was a declarative hint with zero + // readers; the live binding is the page variable whose `source` names this + // component's `id` (ADR-0049 enforce-or-remove). + it('rejects the retired `targetVariable` with its prescription', () => { + expect(() => ElementTextInputPropsSchema.parse({ targetVariable: 'email' })) + .toThrow(/`targetVariable`.*removed.*Delete the key/s); + }); + + it('does not materialize the retired `targetVariable` on a clean parse', () => { + const props = ElementTextInputPropsSchema.parse({}); + expect(props).not.toHaveProperty('targetVariable'); }); it('should accept all input types', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 278e95b40e..d48cd3f043 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1740,7 +1740,22 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ */ limit: z.number().int().positive().optional() .describe('Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50)'), - targetVariable: z.string().optional().describe('Page variable to bind selected record ID(s)'), + /** + * REMOVED (#9198). ADR-0049 enforce-or-remove: a declarative hint with zero + * readers — the live binding runs the other direction, resolved from the + * page variable whose `source` names this component's `id` + * ({@link PageVariableSchema}), so authoring only `targetVariable` bound + * nothing while reporting success. + */ + targetVariable: retiredKey( + '`element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 ' + + '(#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding ' + + "runs the other direction, resolved from the page variable whose `source` names this " + + "component's `id`, so authoring only `targetVariable` bound nothing while reporting " + + 'success. Delete the key; to bind the picked record id, declare it on the variable — ' + + "`variables: [{ name: '', type: 'record_id', source: '' }]`. " + + 'Run `os migrate meta --from 17` to rewrite existing sources automatically.', + ), placeholder: I18nLabelSchema.optional().describe('Placeholder text'), /** Shown in place of the row list when the query returns nothing. */ emptyText: I18nLabelSchema.optional().describe('Text shown when the query returns no records (default "No records")'), @@ -1807,8 +1822,23 @@ export const ElementTextInputPropsSchema = lazySchema(() => strictObject({ required: z.boolean().optional().default(false).describe('Mark the field as required'), disabled: z.boolean().optional().default(false).describe('Disable the input'), description: I18nLabelSchema.optional().describe('Helper text shown below the input'), - targetVariable: z.string().optional() - .describe('Page variable this input writes to. Declarative hint; the live binding resolves via the variable whose `source` equals this component id (see PageVariableSchema).'), + /** + * REMOVED (#9198). ADR-0049 enforce-or-remove: the key's own describe text + * already called it a "declarative hint" that the live binding does not use + * — the binding resolves from the page variable whose `source` names this + * component's `id` ({@link PageVariableSchema}). Zero readers anywhere; an + * author who wrote only `targetVariable` and no variable `source` got an + * input that wrote nothing, with a success receipt (the ADR-0078 shape). + */ + targetVariable: retiredKey( + '`element:text_input` property `targetVariable` was removed in @objectstack/spec 17 ' + + '(#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding ' + + "runs the other direction, resolved from the page variable whose `source` names this " + + "component's `id`, so authoring only `targetVariable` bound nothing while reporting " + + 'success. Delete the key; to bind the typed value, declare it on the variable — ' + + "`variables: [{ name: '', type: 'string', source: '' }]`. " + + 'Run `os migrate meta --from 17` to rewrite existing sources automatically.', + ), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index a1580478db..da30315a49 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -680,10 +680,12 @@ describe('PageVariableSchema record_id type', () => { { id: 'account_picker', type: 'element:record_picker', + // The binding is carried by the VARIABLE's `source` above, not by + // any picker prop — `displayField` (#5775) and `targetVariable` + // (#9198) are both retired. properties: { object: 'account', - displayField: 'name', - targetVariable: 'selected_id', + labelField: 'name', }, }, ],