Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .changeset/element-input-target-variable-retired.md
Original file line numberDiff line numberDiff line change
@@ -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' }]
```

<!-- adr-0087: registered element-input-target-variable-removed -->
4 changes: 2 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: '<var>', type: 'record_id', source: '<this component id>' }]`. Run `os migrate meta --from 17` to rewrite existing sources automatically. |
| **placeholder** | `string \| Record<string, string>` | optional | Placeholder text |
| **emptyText** | `string \| Record<string, string>` | 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. |
Expand All@@ -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<string, string>` | 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: '<var>', type: 'string', source: '<this component id>' }]`. Run `os migrate meta --from 17` to rewrite existing sources automatically. |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


Expand Down
4 changes: 2 additions & 2 deletions packages/spec/authorable-surface/ui.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand Down
142 changes: 141 additions & 1 deletion packages/spec/src/conversions/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<number, readonly MetadataConversion[]>> = {
11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename],
13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition],
Expand DownExpand Up@@ -6803,7 +6943,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
appHiddenToUnpublished,
actionGlobalNavLocationRemoved,
],
18: [fieldMalformedScalePrecisionRemoved, recordChatterPositionVocabulary],
18: [fieldMalformedScalePrecisionRemoved, recordChatterPositionVocabulary, elementInputTargetVariableRemoved],
};

/** Flattened, deterministic list of every conversion the loader knows about. */
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// #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`.
export const entry = 'ui/ElementRecordPickerProps:targetVariable';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// #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).
export const entry = 'ui/ElementTextInputProps:targetVariable';
Loading
Loading