Skip to content

Commit d173125

Browse files
os-steveclaude
andauthored
feat(spec): retire the component-translation submitLabel copy key (#10926, ADR-0049) (#11438)
Option A per the maintainer ruling on #10926 (2026-08-22): drop the key, the submit alias and the PAGE_COMPONENT_COPY_KEYS slot; strict-delete route with guidance tombstones (the face is strictObject); D2 conversion translation-component-submit-label-removed wired into the step-18 chain; pins flipped, not deleted. Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5a916c4 commit d173125

7 files changed

Lines changed: 304 additions & 15 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): retire the component-translation `submitLabel` copy key (#10926, ADR-0049)
6+
7+
<!-- adr-0087: registered translation-component-submit-label-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+
The `pages.<name>.components.<id>` copy face is measured, not mirrored: each
14+
key exists because some component in `ComponentPropsMap` declares it.
15+
`submitLabel`'s only declarer was `element:form`, and #9249 retired that
16+
element whole — so the key had no declared component left to translate, and
17+
the resolver overlay was its only reader. The maintainer ruled retire over
18+
re-anchor (#10926): the live form surface (`object-form`) speaks `submitText`
19+
(`I18nLabelSchema`), localizable at its own authoring site, so re-anchoring
20+
would have widened the face for one word. The acknowledged cost is that the
21+
bespoke-component route loses that one word.
22+
23+
**What is refused:** `submitLabel` in any `pages.<name>.components.<id>`
24+
translation entry, and its `submit` alias spelling — both now land on a
25+
`guidance` prescription in the strict unknown-key rejection (the face is
26+
`.strict()`, so the strict-delete route applies: no `retiredKey()` tombstone,
27+
the shape simply no longer declares the key).
28+
29+
**What stays:** the other five copy keys (`title`, `description`, `label`,
30+
`placeholder`, `emptyText`), the bespoke-component route for them, and the
31+
shared `PAGE_COMPONENT_COPY_KEYS` list (now five entries) that drives both
32+
`translatePage`'s overlay and the CLI `i18n-extract` skeleton — one list, both
33+
sides import it, so extractor and resolver narrow together.
34+
35+
The retirement kit:
36+
37+
- strict-delete at the schema (`packages/spec/src/system/translation.zod.ts`):
38+
key and `submit` alias dropped; `guidance` tombstones carry the prescription
39+
- `PAGE_COMPONENT_COPY_KEYS` drops the slot
40+
(`packages/spec/src/system/i18n-resolver.ts`) — the resolver no longer
41+
overlays the key and the extractor no longer offers it
42+
- ADR-0087 registration: D2 conversion
43+
`translation-component-submit-label-removed` (protocol 18), wired into the
44+
step-18 chain — `os migrate meta --from 17` strips the key from stored
45+
translation bundles and items (pure lossless delete; nothing read it since
46+
#9249)
47+
- pin tests flipped, not deleted (`translation.test.ts` refusal pins assert
48+
the prescription; `i18n-resolver.test.ts` pins that an off-spec bundle entry
49+
carrying the retired key is ignored, not overlaid)
50+
- generated baselines/docs follow the schema (json-schema manifest,
51+
spec-changes, upgrade guide, api-surface signatures, reference docs)
52+
53+
## FROM → TO
54+
55+
```ts
56+
// before — a component-translation entry could carry a submit label
57+
translations: [{
58+
'zh-CN': {
59+
pages: {
60+
sales_home_page: {
61+
components: { new_lead_form: { submitLabel: '创建' } },
62+
},
63+
},
64+
},
65+
}]
66+
67+
// after — delete the key (nothing has read it since #9249); submit copy for
68+
// the live form surface is authored on the component itself, where it is
69+
// localizable inline
70+
{
71+
type: 'object-form',
72+
properties: {
73+
objectName: 'lead',
74+
submitText: { en: 'Create', 'zh-CN': '创建' },
75+
},
76+
}
77+
```

packages/spec/src/conversions/registry.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7176,6 +7176,139 @@ const elementFormRemoved: MetadataConversion = {
71767176
},
71777177
};
71787178

7179+
/**
7180+
* `translation.pages.<name>.components.<id>.submitLabel` — the component-copy
7181+
* key retired with its only declarer (protocol 18, #10926, ADR-0049).
7182+
*
7183+
* The face is measured, not mirrored: each copy key exists because some
7184+
* component in `ComponentPropsMap` declares it, and `submitLabel`'s only
7185+
* declarer was `element:form` — retired whole by #9249 (`element-form-removed`
7186+
* above). The maintainer ruled retire over re-anchor (#10926): the live form
7187+
* surface's submit copy is `object-form`'s `submitText` (`I18nLabelSchema`),
7188+
* localizable at its own authoring site, so re-anchoring would have widened
7189+
* the face for one word. The key, its `submit` alias and its
7190+
* `PAGE_COMPONENT_COPY_KEYS` slot are gone; the schema rejection carries the
7191+
* prescription.
7192+
*
7193+
* Pure lossless delete — since #9249 no resolver overlaid the key, so a stored
7194+
* translation kept a string nothing read. Both authored shapes are walked: a
7195+
* bundle entry (locale → data map, `stack.translations`' declared shape) and a
7196+
* bare data/item entry (groups at the top level — the shape stored `translation`
7197+
* items replay through, the `translation-validation-messages-removed`
7198+
* precedent).
7199+
*/
7200+
const translationComponentSubmitLabelRemoved: MetadataConversion = {
7201+
id: 'translation-component-submit-label-removed',
7202+
toMajor: 18,
7203+
retiredFromLoadPath: true,
7204+
surface: 'translation.pages.components.submitLabel',
7205+
summary:
7206+
"translation component-copy key 'submitLabel' removed (#10926 — its only declared carrier, "
7207+
+ "'element:form', retired whole in #9249, so the resolver no longer overlays it and a stored "
7208+
+ "string was read by nothing; the live form surface's submit copy is 'object-form''s "
7209+
+ "'submitText', localized at its own authoring site)",
7210+
apply(stack, emit) {
7211+
const stripFromData = (data: Record<string, unknown>, path: string): Record<string, unknown> => {
7212+
const pages = data.pages;
7213+
if (!isDict(pages)) return data;
7214+
let pagesChanged = false;
7215+
const nextPages: Record<string, unknown> = { ...pages };
7216+
for (const [pageName, page] of Object.entries(pages)) {
7217+
if (!isDict(page) || !isDict(page.components)) continue;
7218+
let componentsChanged = false;
7219+
const nextComponents: Record<string, unknown> = { ...page.components };
7220+
for (const [id, entry] of Object.entries(page.components)) {
7221+
if (!isDict(entry)) continue;
7222+
const stripped = stripKeys(entry, ['submitLabel'], emit, `${path}.pages.${pageName}.components.${id}`);
7223+
if (stripped === entry) continue;
7224+
nextComponents[id] = stripped;
7225+
componentsChanged = true;
7226+
}
7227+
if (!componentsChanged) continue;
7228+
nextPages[pageName] = { ...page, components: nextComponents };
7229+
pagesChanged = true;
7230+
}
7231+
return pagesChanged ? { ...data, pages: nextPages } : data;
7232+
};
7233+
return mapCollection(stack, 'translations', (entry, path) => {
7234+
// Bare data/item shape: the groups sit at the entry's top level.
7235+
let next = stripFromData(entry, path);
7236+
// Bundle shape: locale code → data. Judged structurally (a dict whose
7237+
// `pages` is a dict) rather than by key spelling — `LocaleSchema` is an
7238+
// open string, so the locale keys cannot be enumerated. A strip-only
7239+
// walk makes a false positive a no-op: it removes nothing unless the
7240+
// exact `pages.<name>.components.<id>.submitLabel` path is present.
7241+
for (const [locale, data] of Object.entries(next)) {
7242+
if (!isDict(data) || !isDict(data.pages)) continue;
7243+
const stripped = stripFromData(data, `${path}.${locale}`);
7244+
if (stripped === data) continue;
7245+
next = next === entry ? { ...entry } : next;
7246+
next[locale] = stripped;
7247+
}
7248+
return next;
7249+
});
7250+
},
7251+
fixture: {
7252+
before: {
7253+
translations: [
7254+
{
7255+
// The bundle shape `stack.translations` declares.
7256+
'zh-CN': {
7257+
pages: {
7258+
sales_home_page: {
7259+
components: {
7260+
new_lead_form: { submitLabel: '创建' },
7261+
// Live keys on a neighbor ride through untouched.
7262+
quick_create: { title: '快速新建' },
7263+
},
7264+
},
7265+
},
7266+
},
7267+
},
7268+
{
7269+
// The bare item shape stored `translation` rows replay through.
7270+
name: 'ja_jp',
7271+
locale: 'ja-JP',
7272+
pages: {
7273+
sales_home_page: {
7274+
components: { new_lead_form: { submitLabel: '作成' } },
7275+
},
7276+
},
7277+
},
7278+
],
7279+
},
7280+
after: {
7281+
translations: [
7282+
{
7283+
'zh-CN': {
7284+
pages: {
7285+
sales_home_page: {
7286+
components: {
7287+
new_lead_form: {},
7288+
quick_create: { title: '快速新建' },
7289+
},
7290+
},
7291+
},
7292+
},
7293+
},
7294+
{
7295+
name: 'ja_jp',
7296+
locale: 'ja-JP',
7297+
pages: {
7298+
sales_home_page: {
7299+
components: { new_lead_form: {} },
7300+
},
7301+
},
7302+
},
7303+
],
7304+
},
7305+
// One per stripped key instance: one in the bundle-shaped entry, one in
7306+
// the item-shaped entry. The emptied component bag stays — the conversion
7307+
// strips KEYS, and deleting the bag would be a second, unprescribed edit.
7308+
expectedNotices: 2,
7309+
},
7310+
};
7311+
71797312
/**
71807313
* `field.inlineColumns[]` / `field.relatedListColumns[]` — the mechanical half
71817314
* of the #9227 strict-element narrowing (protocol 18).
@@ -7752,6 +7885,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
77527885
metricFiltersRemoved,
77537886
recordHighlightsFieldIconRemoved,
77547887
mappingLookupParamsRemoved,
7888+
translationComponentSubmitLabelRemoved,
77557889
],
77567890
};
77577891

packages/spec/src/migrations/registry.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5143,7 +5143,19 @@ const step18: MigrationStep = {
51435143
'fails the row (`import_reference_not_found`), with or without the key. The eleven ' +
51445144
'alias spellings convert to guidance so every spelling lands on the prescription; the ' +
51455145
'mechanical conversion strips the four keys from stored sources (pure lossless ' +
5146-
'deletes — none ever had an effect to lose).',
5146+
'deletes — none ever had an effect to lose). ' +
5147+
'Finally, it retires the component-translation copy key ' +
5148+
'`pages.<name>.components.<id>.submitLabel` and its `submit` alias (#10926, ADR-0049; ' +
5149+
'maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key ' +
5150+
'exists because some component in `ComponentPropsMap` declares it — and ' +
5151+
'`submitLabel`\'s only declarer was `element:form`, retired whole above (#9249), so ' +
5152+
'the key had no declared component left to translate and the resolver overlay was ' +
5153+
'its only reader. Retire won over re-anchor because the live form surface ' +
5154+
'(`object-form`) speaks `submitText` (`I18nLabelSchema`), localizable at its own ' +
5155+
'authoring site; re-anchoring would have widened the face for one word. The ' +
5156+
'mechanical conversion strips the key from stored bundles and items (pure lossless ' +
5157+
'delete — nothing read it since #9249), at the acknowledged cost of dropping the ' +
5158+
'bespoke-component route for that one word.',
51475159
conversionIds: [
51485160
'field-malformed-scale-precision-removed',
51495161
'record-chatter-position-vocabulary',
@@ -5154,6 +5166,7 @@ const step18: MigrationStep = {
51545166
'metric-filters-removed',
51555167
'record-highlights-field-icon-removed',
51565168
'mapping-lookup-params-removed',
5169+
'translation-component-submit-label-removed',
51575170
],
51585171
semantic: [
51595172
// One file per entry under `entries/semantic/`, concatenated here sorted by

packages/spec/src/system/i18n-resolver.test.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,7 +1078,6 @@ describe('translatePage', () => {
10781078
kpi_revenue_won: { label: '已赢收入' },
10791079
ai_briefing: { title: '询问 AI 助手', description: '从右侧边缘打开助手面板。' },
10801080
lead_picker: { placeholder: '搜索线索…', emptyText: '暂无记录' },
1081-
new_lead_form: { submitLabel: '创建' },
10821081
},
10831082
},
10841083
},
@@ -1103,11 +1102,11 @@ describe('translatePage', () => {
11031102
{ type: 'element:kpi', id: 'kpi_revenue_won', properties: { label: 'Revenue (Won)', value: 42 } },
11041103
{ type: 'page:card', id: 'ai_briefing', properties: { title: 'Ask the AI Assistant', description: 'Open the assistant panel from the right edge…' } },
11051104
{ type: 'element:record_picker', id: 'lead_picker', properties: { object: 'lead', placeholder: 'Search leads…', emptyText: 'No records' } },
1106-
// Was `element:form` until #9249 retired that element whole; the
1107-
// resolver is id-addressed and type-agnostic, and the copy-key face
1108-
// documents bespoke component types as a legal route for the same
1109-
// vocabulary — so the `submitLabel` pin rides one of those, pending
1110-
// the #10926 carrier decision.
1105+
// Was `element:form` until #9249 retired that element whole, then a
1106+
// bespoke type carrying the `submitLabel` pin pending #10926. That
1107+
// ruling retired the key from the copy face, so the node now pins
1108+
// the NEGATIVE: a bespoke component's `submitLabel` is no longer
1109+
// overlaid, however the bundle spells it.
11111110
{ type: 'hotcrm:quick_form', id: 'new_lead_form', properties: { object: 'lead', submitLabel: 'Create' } },
11121111
{ type: 'page:card', id: 'untranslated_card', properties: { title: 'Still English' } },
11131112
],
@@ -1129,7 +1128,26 @@ describe('translatePage', () => {
11291128
const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' });
11301129
expect(byId(out, 'lead_picker').properties.placeholder).toBe('搜索线索…');
11311130
expect(byId(out, 'lead_picker').properties.emptyText).toBe('暂无记录');
1132-
expect(byId(out, 'new_lead_form').properties.submitLabel).toBe('创建');
1131+
});
1132+
1133+
it('no longer overlays `submitLabel` — the key retired from the copy face (#10926)', () => {
1134+
// Flipped, not deleted: this used to assert the overlay ('创建'). The
1135+
// schema now refuses `submitLabel` in a bundle, but the resolver is
1136+
// deliberately schema-independent (it reads whatever object it is
1137+
// handed — stored rows predating the retirement reach it via the raw
1138+
// sync path), so the negative is worth pinning on its own: an off-spec
1139+
// entry carrying the retired key must be IGNORED, not overlaid.
1140+
const offSpecBundle = {
1141+
'zh-CN': {
1142+
pages: {
1143+
sales_home_page: {
1144+
components: { new_lead_form: { submitLabel: '创建' } },
1145+
},
1146+
},
1147+
},
1148+
} as unknown as TranslationBundle;
1149+
const out = translatePage(homePage(), offSpecBundle, { locale: 'zh-CN' });
1150+
expect(byId(out, 'new_lead_form').properties.submitLabel).toBe('Create');
11331151
});
11341152

11351153
it('preserves non-copy properties alongside the overlay', () => {

packages/spec/src/system/i18n-resolver.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -882,11 +882,12 @@ function lookupPageAttr(
882882
* skeleton bundle. Two hand-maintained copies of this list would drift into the
883883
* classic pair of failures — the extractor offering a key the resolver ignores,
884884
* or omitting one it reads — so there is one list and both sides import it.
885-
* `translation.zod.ts` declares the same six; `translation.test.ts` pins the
886-
* two in agreement.
885+
* `translation.zod.ts` declares the same five; `translation.test.ts` pins the
886+
* two in agreement. (`submitLabel` retired with its only declarer,
887+
* `element:form` — #9249 / #10926.)
887888
*/
888889
export const PAGE_COMPONENT_COPY_KEYS = [
889-
'title', 'description', 'label', 'placeholder', 'emptyText', 'submitLabel',
890+
'title', 'description', 'label', 'placeholder', 'emptyText',
890891
] as const;
891892

892893
export type PageComponentCopyKey = typeof PAGE_COMPONENT_COPY_KEYS[number];

packages/spec/src/system/translation.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,11 +831,34 @@ describe('translation unknown-key strictness (#4001)', () => {
831831
kpi_revenue_won: { label: 'Revenue (Won)' },
832832
ai_briefing: { title: 'Ask the AI', description: 'Open the panel.' },
833833
lead_picker: { placeholder: 'Search…', emptyText: 'No records' },
834-
new_lead_form: { submitLabel: 'Create' },
835834
});
836835
expect(result.success).toBe(true);
837836
});
838837

838+
it('refuses `submitLabel` with the retirement prescription (#10926)', () => {
839+
// Flipped, not deleted: until #10926 this case pinned `submitLabel` as
840+
// an accepted copy key (latterly on a bespoke component type, after
841+
// #9249 retired `element:form`, its only spec-declared carrier). The
842+
// maintainer ruled retire over re-anchor, so the same authored shape now
843+
// pins the rejection — and the rejection must carry the upgrade.
844+
const result = parse({ new_lead_form: { submitLabel: 'Create' } });
845+
expect(result.success).toBe(false);
846+
const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message;
847+
expect(message).toContain('`submitLabel` was removed in @objectstack/spec 17 (#10926');
848+
expect(message).toContain('`submitText`');
849+
});
850+
851+
it('refuses the retired `submit` alias spelling with the same story', () => {
852+
// `submit` was an alias (rejection-path suggestion) pointing at
853+
// `submitLabel`; with the target retired the alias converts to guidance
854+
// so the spelling lands on the prescription instead of a dangling
855+
// rename suggestion.
856+
const result = parse({ new_lead_form: { submit: 'Create' } });
857+
expect(result.success).toBe(false);
858+
const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message;
859+
expect(message).toContain('`submit` was the alias spelling of `submitLabel`');
860+
});
861+
839862
it('stays `.strict()` — an invented key is still refused', () => {
840863
const result = parse({ quick_create: { tooltip: 'Create a record' } });
841864
expect(result.success).toBe(false);

0 commit comments

Comments
 (0)