Skip to content

Commit 7ea1372

Browse files
os-zhuangclaude
andauthored
feat(spec): retire inert targetVariable on element:text_input / element:record_picker (#9198, ADR-0049) (#9221)
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. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5904b05 commit 7ea1372

10 files changed

Lines changed: 374 additions & 16 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): retire the inert `targetVariable` key from `element:text_input` and `element:record_picker` (#9198, ADR-0049)
6+
7+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
8+
launch-window convention ships it as `minor`; the migration prescription is
9+
registered under protocol major 18, where `os migrate meta` users will look).
10+
11+
`targetVariable` on the two SDUI input elements was a declarative hint with
12+
zero readers in any repo — its own describe text said the live binding
13+
"resolves via the variable whose `source` equals this component id"
14+
(`PageVariableSchema`), and that reverse lookup
15+
(`usePageVariableBinding(schema?.id)` in the console renderer) is the only
16+
binding mechanism that exists. Measured (objectstack-ai/objectui#3834,
17+
re-verified at retirement): no renderer, hook or runtime in objectui,
18+
framework or cloud reads the key. An author — human or AI — who read the
19+
manifest, wrote `targetVariable`, and skipped the variable's `source` got an
20+
input that wrote nothing, with a success receipt and no diagnostic anywhere.
21+
Same disposition as the sibling inert hint settled by retirement in objectui
22+
PR #4794.
23+
24+
**What is refused:** an authored `targetVariable` on `element:text_input` or
25+
`element:record_picker` properties. Both keys are `retiredKey()` tombstones —
26+
refused at `tsc` (typed `never`) and at the parse, message carrying the
27+
prescription.
28+
29+
**What stays accepted:** every text input / record picker without the key,
30+
byte-identically — including the working binding (`variables[].source`), which
31+
is untouched. `targetVariable` on `element:filter` is a different surface and
32+
is not part of this disposition. Runtime behaviour is unchanged: nothing ever
33+
read the key, so removing it removes no behaviour.
34+
35+
The retirement kit:
36+
37+
- tombstones at the schema (`packages/spec/src/ui/component.zod.ts`)
38+
- ADR-0087 registration: retired-key entries
39+
`ui/ElementTextInputProps:targetVariable` +
40+
`ui/ElementRecordPickerProps:targetVariable` and the D2 conversion
41+
`element-input-target-variable-removed` (protocol 18), wired into the step-18
42+
chain — `os migrate meta --from 17` strips the key from old sources (pure
43+
lossless delete; it never had an effect to lose)
44+
- pin tests (`component.test.ts` — refusal carries the prescription; clean
45+
parses materialize nothing)
46+
- generated baselines/docs follow the schema (`authorable-surface/`,
47+
`json-schema.manifest/`, spec-changes, upgrade guide, reference docs)
48+
49+
## FROM → TO
50+
51+
```ts
52+
// before — parsed green; the hint bound nothing
53+
{
54+
id: 'email_input',
55+
type: 'element:text_input',
56+
properties: { inputType: 'email', targetVariable: 'contact_email' },
57+
}
58+
59+
// after — delete the key; declare the binding on the page variable instead
60+
{
61+
id: 'email_input',
62+
type: 'element:text_input',
63+
properties: { inputType: 'email' },
64+
}
65+
// page.variables: [{ name: 'contact_email', type: 'string', source: 'email_input' }]
66+
```
67+
68+
<!-- adr-0087: registered element-input-target-variable-removed -->

content/docs/references/ui/component.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ const result = AIChatWindowProps.parse(data);
149149
| **filter** | `any` | optional | Filter criteria for available records |
150150
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order — synonym of the component-level `dataSource.sort`, which takes precedence when both are set |
151151
| **limit** | `integer` | optional | Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50) |
152-
| **targetVariable** | `string` | optional | Page variable to bind selected record ID(s) |
152+
| **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. |
153153
| **placeholder** | `string \| Record<string, string>` | optional | Placeholder text |
154154
| **emptyText** | `string \| Record<string, string>` | optional | Text shown when the query returns no records (default "No records") |
155155
| **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);
173173
| **required** | `boolean` | optional (default: `false`) | Mark the field as required |
174174
| **disabled** | `boolean` | optional (default: `false`) | Disable the input |
175175
| **description** | `string \| Record<string, string>` | optional | Helper text shown below the input |
176-
| **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). |
176+
| **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. |
177177
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
178178

179179

packages/spec/authorable-surface/ui.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@
422422
"ui/ElementRecordPickerProps:placeholder",
423423
"ui/ElementRecordPickerProps:searchFields [RETIRED]",
424424
"ui/ElementRecordPickerProps:sort",
425-
"ui/ElementRecordPickerProps:targetVariable",
425+
"ui/ElementRecordPickerProps:targetVariable [RETIRED]",
426426
"ui/ElementRecordPickerProps:valueField",
427427
"ui/ElementTextInputProps:aria",
428428
"ui/ElementTextInputProps:defaultValue",
@@ -432,7 +432,7 @@
432432
"ui/ElementTextInputProps:label",
433433
"ui/ElementTextInputProps:placeholder",
434434
"ui/ElementTextInputProps:required",
435-
"ui/ElementTextInputProps:targetVariable",
435+
"ui/ElementTextInputProps:targetVariable [RETIRED]",
436436
"ui/ElementTextProps:align",
437437
"ui/ElementTextProps:aria",
438438
"ui/ElementTextProps:content",

packages/spec/src/conversions/registry.ts

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6731,6 +6731,146 @@ const recordChatterPositionVocabulary: MetadataConversion = {
67316731
},
67326732
};
67336733

6734+
/**
6735+
* `element:text_input.targetVariable` / `element:record_picker.targetVariable`
6736+
* — a declarative hint with zero readers (protocol 18, #9198, ADR-0049).
6737+
*
6738+
* The key's own describe text admitted the split: "Declarative hint; the live
6739+
* binding resolves via the variable whose `source` equals this component id".
6740+
* Measured (objectstack-ai/objectui#3834, re-verified at retirement time):
6741+
* no renderer, hook or runtime in objectui, framework or cloud reads it — the
6742+
* console binds input elements through `usePageVariableBinding(schema?.id)`,
6743+
* a reverse lookup over `PageVariableSchema.source`. So an author (human or
6744+
* AI) who read the manifest, wrote `targetVariable` and skipped the
6745+
* variable's `source` got an input that wrote nothing, with a success receipt
6746+
* and no diagnostic anywhere — the ADR-0078 shape, on the exact surface AI
6747+
* authors write from. Enforce-or-remove: removed, not deprecated; the same
6748+
* disposition its sibling inert hint reached in objectui PR #4794.
6749+
*
6750+
* Pure lossless deletes — the key never had an effect to lose. The live
6751+
* binding (`variables[].source`) is untouched, and the tombstone's
6752+
* prescription tells the author how to declare it.
6753+
*/
6754+
const elementInputTargetVariableRemoved: MetadataConversion = {
6755+
id: 'element-input-target-variable-removed',
6756+
toMajor: 18,
6757+
retiredFromLoadPath: true,
6758+
surface:
6759+
'page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable',
6760+
summary:
6761+
"text-input/record-picker component prop 'targetVariable' removed (#9198 — a declarative "
6762+
+ 'hint nothing read; the live binding resolves from the page variable whose `source` names '
6763+
+ 'the component id)',
6764+
apply(stack, emit) {
6765+
return mapPageComponents(stack, (component, path) => {
6766+
if (component.type !== 'element:text_input' && component.type !== RECORD_PICKER_COMPONENT_TYPE) {
6767+
return component;
6768+
}
6769+
const properties = component.properties;
6770+
if (!isDict(properties)) return component;
6771+
const stripped = stripKeys(properties, ['targetVariable'], emit, `${path}.properties`);
6772+
if (stripped === properties) return component;
6773+
return { ...component, properties: stripped };
6774+
});
6775+
},
6776+
fixture: {
6777+
before: {
6778+
pages: [
6779+
{
6780+
name: 'contact_capture',
6781+
variables: [
6782+
// The LIVE half of the binding — the variable's `source` names the
6783+
// component id, and nothing here touches it.
6784+
{ name: 'contact_email', type: 'string', source: 'email_input' },
6785+
],
6786+
regions: [
6787+
{
6788+
name: 'main',
6789+
components: [
6790+
{
6791+
id: 'email_input',
6792+
type: 'element:text_input',
6793+
properties: { inputType: 'email', targetVariable: 'contact_email' },
6794+
},
6795+
{ type: 'element:record_picker', properties: { object: 'showcase_project', targetVariable: 'selected_id' } },
6796+
// `targetVariable` on `element:filter` is a DIFFERENT surface,
6797+
// outside #9198's disposition — untouched here.
6798+
{ type: 'element:filter', properties: { object: 'a', fields: ['status'], targetVariable: 'active_filter' } },
6799+
// Nested one container down (#6775) — the walk descends.
6800+
{
6801+
type: 'page:card',
6802+
properties: {
6803+
title: 'Pick one',
6804+
children: [
6805+
{ type: 'element:record_picker', properties: { object: 'b', targetVariable: 'picked' } },
6806+
],
6807+
},
6808+
},
6809+
],
6810+
},
6811+
],
6812+
},
6813+
// The named-slot shape, on a slotted record page.
6814+
{
6815+
name: 'contact_detail',
6816+
kind: 'slotted',
6817+
regions: [],
6818+
slots: {
6819+
details: [
6820+
{ type: 'element:text_input', properties: { label: 'Note', targetVariable: 'note_draft' } },
6821+
],
6822+
},
6823+
},
6824+
],
6825+
},
6826+
after: {
6827+
pages: [
6828+
{
6829+
name: 'contact_capture',
6830+
variables: [
6831+
{ name: 'contact_email', type: 'string', source: 'email_input' },
6832+
],
6833+
regions: [
6834+
{
6835+
name: 'main',
6836+
components: [
6837+
{
6838+
id: 'email_input',
6839+
type: 'element:text_input',
6840+
properties: { inputType: 'email' },
6841+
},
6842+
{ type: 'element:record_picker', properties: { object: 'showcase_project' } },
6843+
{ type: 'element:filter', properties: { object: 'a', fields: ['status'], targetVariable: 'active_filter' } },
6844+
{
6845+
type: 'page:card',
6846+
properties: {
6847+
title: 'Pick one',
6848+
children: [
6849+
{ type: 'element:record_picker', properties: { object: 'b' } },
6850+
],
6851+
},
6852+
},
6853+
],
6854+
},
6855+
],
6856+
},
6857+
{
6858+
name: 'contact_detail',
6859+
kind: 'slotted',
6860+
regions: [],
6861+
slots: {
6862+
details: [
6863+
{ type: 'element:text_input', properties: { label: 'Note' } },
6864+
],
6865+
},
6866+
},
6867+
],
6868+
},
6869+
// One per stripped key — the filter element keeps its (out-of-scope) key.
6870+
expectedNotices: 4,
6871+
},
6872+
};
6873+
67346874
export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConversion[]>> = {
67356875
11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename],
67366876
13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition],
@@ -6803,7 +6943,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
68036943
appHiddenToUnpublished,
68046944
actionGlobalNavLocationRemoved,
68056945
],
6806-
18: [fieldMalformedScalePrecisionRemoved, recordChatterPositionVocabulary],
6946+
18: [fieldMalformedScalePrecisionRemoved, recordChatterPositionVocabulary, elementInputTargetVariableRemoved],
68076947
};
68086948

68096949
/** Flattened, deterministic list of every conversion the loader knows about. */
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// #9198 — ADR-0049 enforce-or-remove. `targetVariable` on
4+
// `element:record_picker` was a declarative hint with zero readers: the picker
5+
// writes the selected record id through the reverse binding — the page
6+
// variable whose `source` names this component's `id` (PageVariableSchema;
7+
// `usePageVariableBinding(schema?.id)` in objectui's console renderer) — and
8+
// nothing anywhere read this key. Measured (objectstack-ai/objectui#3834,
9+
// re-verified at retirement time): zero production readers in objectui,
10+
// framework and cloud; the only repo-wide hits were the reverse-parity gate's
11+
// exemption block and spec's own accept tests. Same silent-no-op hazard and
12+
// same disposition as the `element:text_input` twin registered beside this
13+
// entry, and as the #5775 record-picker inert keys one shape over.
14+
//
15+
// Registered under 18, not 17: v17.0.0 was cut before this landed, so the
16+
// tombstone ships on the 17.x line (launch-window convention: accept-set
17+
// narrowings ride minor releases) and the prescription lives at the major
18+
// boundary where `migrate meta` users look (the #8495 / PR #8666 precedent).
19+
// Sources are rewritten by the D2 conversion
20+
// `element-input-target-variable-removed`.
21+
export const entry = 'ui/ElementRecordPickerProps:targetVariable';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// #9198 — ADR-0049 enforce-or-remove. `targetVariable` on `element:text_input`
4+
// was a declarative hint with zero readers: its own describe text said the
5+
// live binding "resolves via the variable whose `source` equals this component
6+
// id" (PageVariableSchema), and that reverse lookup
7+
// (`usePageVariableBinding(schema?.id)` in objectui's console renderer) is the
8+
// only binding mechanism that exists. Measured (objectstack-ai/objectui#3834,
9+
// re-verified at retirement time): no production reader in objectui, framework
10+
// or cloud — the only repo-wide hits were the reverse-parity gate's exemption
11+
// block (which cites the origin card) and spec's own accept tests. An author
12+
// who wrote `targetVariable` and skipped the variable's `source` got an input
13+
// that wrote nothing, with a success receipt — the ADR-0078 silent-no-op
14+
// shape, on the exact surface AI authors write from. Same disposition as its
15+
// sibling inert hint (objectui#3829, settled by retirement in objectui
16+
// PR #4794).
17+
//
18+
// Registered under 18, not 17: v17.0.0 was cut before this landed, so the
19+
// tombstone ships on the 17.x line (launch-window convention: accept-set
20+
// narrowings ride minor releases) and the prescription lives at the major
21+
// boundary where `migrate meta` users look (the #8495 / PR #8666 precedent).
22+
// Sources are rewritten by the D2 conversion
23+
// `element-input-target-variable-removed` (a page component IS a stack
24+
// collection member, unlike the `kernel/Manifest:loading` family).
25+
export const entry = 'ui/ElementTextInputProps:targetVariable';

0 commit comments

Comments
 (0)