From 33a1af012cec6fb94265b83c31c52de80229e2b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:20:58 +0000 Subject: [PATCH 1/3] fix(spec): a translation item is named in snake_case, the locale tag goes in `locale` `TranslationItemSchema.name` called the locale tag (`zh-CN`) the conventional item name, which the save door's item-name grammar refuses. The describe, its comment, the generated reference row and the sync's skip warning now say snake_case (`zh_cn`) with the BCP-47 tag in `locale`. The door and the read-side fallback keep their behaviour; a door pin covers both names. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzhKYT8L13Bgj8icT3i --- .../26128-translation-item-snake-case-name.md | 10 ++++++++ .../docs/references/system/translation.mdx | 2 +- .../fallbacks/authored-translation-sync.ts | 2 +- .../src/protocol.item-name-grammar.test.ts | 24 +++++++++++++++++++ packages/spec/src/system/translation.zod.ts | 15 +++++++----- 5 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 .changeset/26128-translation-item-snake-case-name.md diff --git a/.changeset/26128-translation-item-snake-case-name.md b/.changeset/26128-translation-item-snake-case-name.md new file mode 100644 index 00000000000..be5c073432d --- /dev/null +++ b/.changeset/26128-translation-item-snake-case-name.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `TranslationItemSchema.name` no longer tells authors to name a translation item after its locale tag + +Clause-②: no +Metadata-protocol: none + +The `name` describe said a translation item was "conventionally the locale code (`zh-CN`)", but the metadata save door's item-name grammar refuses `zh-CN` (uppercase and a hyphen), so an author or agent following the describe got a 400 `INVALID_REQUEST` on the first save. The describe now says a translation item is named in snake_case like every metadata item (e.g. `zh_cn`), with the BCP-47 tag in `locale`. The save door and the runtime sync's read-side name fallback for rows stored before `locale` became required are unchanged; the sync's skip warning now points authors at `locale` only. diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index d16708d3bf6..66228eff7e0 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -737,7 +737,7 @@ One locale of translations — the `translation` metadata type | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") | -| **name** | `string` | optional | Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent | +| **name** | `string` | optional | Item name — snake_case like every metadata item (e.g. `zh_cn`); the BCP-47 tag goes in `locale` | | **label** | `string` | optional | Human-readable label shown in metadata lists | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/packages/core/src/fallbacks/authored-translation-sync.ts b/packages/core/src/fallbacks/authored-translation-sync.ts index 05e37c6b9a3..b116f4ceddc 100644 --- a/packages/core/src/fallbacks/authored-translation-sync.ts +++ b/packages/core/src/fallbacks/authored-translation-sync.ts @@ -192,7 +192,7 @@ export async function readAuthoredTranslationLayer( if (!locale) { logger?.warn?.( `[i18n] authored translation '${row?.name}' has no resolvable locale ` - + "(set the top-level 'locale', or name the item after its BCP-47 locale) — skipped", + + "(set the top-level 'locale' to its BCP-47 tag, e.g. 'zh-CN') — skipped", ); continue; } diff --git a/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts b/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts index 3e98e36290e..03b8f897ce3 100644 --- a/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts +++ b/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts @@ -300,3 +300,27 @@ describe('#12194 — the refusal is scoped to the doors that MINT or PROMOTE', ( ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); }); }); + +describe('#26128 — a translation item is named in snake_case, the BCP-47 tag goes in `locale`', () => { + // `TranslationItemSchema.name` once called the locale tag (`zh-CN`) the + // conventional item name, while this door refused it. The describe moved; + // the door did not. Both halves are pinned here so they cannot drift again. + const TRANSLATION_BODY = { locale: 'zh-CN', label: '简体中文' }; + + it('refuses a tag-shaped item name (`zh-CN`) with the grammar refusal and persists nothing', async () => { + const { protocol, rows } = makeProtocol(); + await expect( + protocol.saveMetaItem({ type: 'translation', name: 'zh-CN', item: { ...TRANSLATION_BODY, name: 'zh-CN' } }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400, message: expect.stringMatching(GRAMMAR_MESSAGE) }); + expect(metaRows(rows)).toHaveLength(0); + }); + + it('accepts the snake_case name (`zh_cn`) with `locale: \'zh-CN\'`', async () => { + const { protocol, rows } = makeProtocol(); + const result = await protocol.saveMetaItem({ type: 'translation', name: 'zh_cn', item: { ...TRANSLATION_BODY, name: 'zh_cn' } }); + expect(result.success).toBe(true); + expect(metaRows(rows)).toHaveLength(1); + expect(metaRows(rows)[0]!.name).toBe('zh_cn'); + expect(JSON.parse(metaRows(rows)[0]!.metadata).locale).toBe('zh-CN'); + }); +}); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 525953111fe..37f5097215e 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -2220,12 +2220,15 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ // Undeclared, they were stripped on every parse, so the metadata door quietly // discarded an item's own name on the way through. // - // Not snake_case-constrained the way `job.name` is: translation items are - // conventionally named after their locale (`zh-CN`), and the runtime sync - // reads `row.name` as a locale fallback for exactly that reason. Optional - // because `locale`, not `name`, is this type's required identity — that call - // was deliberate (a skipped item is invisible) and is not being reopened. - name: z.string().optional().describe('Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent'), + // Named like every other metadata item: snake_case (`zh_cn`), with the BCP-47 + // tag in `locale`. The save door's item-name grammar + // (`METADATA_ITEM_NAME_PATTERN`) refuses a tag-shaped name such as `zh-CN`, + // so this shape does not re-check it. The runtime sync still reads a + // tag-shaped `row.name` as a locale fallback, but only for rows stored before + // `locale` became required. Optional because `locale`, not `name`, is this + // type's required identity — that call was deliberate (a skipped item is + // invisible) and is not being reopened. + name: z.string().optional().describe('Item name — snake_case like every metadata item (e.g. `zh_cn`); the BCP-47 tag goes in `locale`'), label: z.string().optional().describe('Human-readable label shown in metadata lists'), // ADR-0010 — runtime protection envelope (internal — set by the loader). From ad3a211fef67586f43e53892ce70a7a2d06dc48e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:13:40 +0000 Subject: [PATCH 2/3] fix(spec): changeset names core's warn text; objectql test comment follows the snake_case naming Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzhKYT8L13Bgj8icT3i --- .changeset/26128-translation-item-snake-case-name.md | 3 +++ packages/objectql/src/protocol-publish-canonical-fold.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/26128-translation-item-snake-case-name.md b/.changeset/26128-translation-item-snake-case-name.md index be5c073432d..c0b41cc1934 100644 --- a/.changeset/26128-translation-item-snake-case-name.md +++ b/.changeset/26128-translation-item-snake-case-name.md @@ -1,5 +1,6 @@ --- '@objectstack/spec': patch +'@objectstack/core': patch --- fix(spec): `TranslationItemSchema.name` no longer tells authors to name a translation item after its locale tag @@ -8,3 +9,5 @@ Clause-②: no Metadata-protocol: none The `name` describe said a translation item was "conventionally the locale code (`zh-CN`)", but the metadata save door's item-name grammar refuses `zh-CN` (uppercase and a hyphen), so an author or agent following the describe got a 400 `INVALID_REQUEST` on the first save. The describe now says a translation item is named in snake_case like every metadata item (e.g. `zh_cn`), with the BCP-47 tag in `locale`. The save door and the runtime sync's read-side name fallback for rows stored before `locale` became required are unchanged; the sync's skip warning now points authors at `locale` only. + +`@objectstack/core`: the authored-translation sync's no-locale skip warning now tells authors to set `locale` to the item's BCP-47 tag (e.g. `zh-CN`), instead of suggesting they name the item after it. diff --git a/packages/objectql/src/protocol-publish-canonical-fold.test.ts b/packages/objectql/src/protocol-publish-canonical-fold.test.ts index d0ea71a2e9b..0a14d0f3c0a 100644 --- a/packages/objectql/src/protocol-publish-canonical-fold.test.ts +++ b/packages/objectql/src/protocol-publish-canonical-fold.test.ts @@ -265,8 +265,8 @@ async function refusalOf(fn: () => Promise): Promise<{ code?: string; s /** * A `translation` body. `TranslationItemSchema` requires `locale`; `name` is - * declared and optional, and is deliberately NOT snake_case-constrained for - * this type (translation items are conventionally named after their locale). + * declared and optional; like every metadata item it is named in snake_case + * (`zh_cn`), with the BCP-47 tag in `locale`. */ const translationBody = (label: string) => ({ locale: 'zh-CN', name: 'zh_cn', label }); From e17cd5817ec1bb1e7ef8fff0004a74478e1fdb34 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:10:55 +0000 Subject: [PATCH 3/3] chore(spec): regenerate the translation reference on the merged tree Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzhKYT8L13Bgj8icT3i --- .../docs/references/system/translation.mdx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 66228eff7e0..d45e85fcdc5 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -66,6 +66,7 @@ Translation data for a single field | **help** | `string` | optional | Translated help text | | **placeholder** | `string` | optional | Translated placeholder text for form inputs | | **options** | `Record` | optional | Option value to translated label map | +| **inlineTitle** | `string` | optional | Translated inline master-detail grid title — overlays the field's own `inlineTitle` | --- @@ -90,7 +91,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -107,6 +108,7 @@ Translation data for a single field | **help** | `string` | optional | Translated help text | | **placeholder** | `string` | optional | Translated placeholder text for form inputs | | **options** | `Record` | optional | Option value to translated label map | +| **inlineTitle** | `string` | optional | Translated inline master-detail grid title — overlays the field's own `inlineTitle` | ### Nested Shape: `ObjectTranslationData._views[string]` @@ -177,7 +179,7 @@ Platform translation data — the per-app groups plus the platform-only `setting | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -195,7 +197,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -254,6 +256,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `PlatformTranslationData.pages[string]` @@ -334,7 +337,7 @@ Served translation data — the platform groups the browser reads, without the s | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -351,7 +354,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -410,6 +413,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `ServedTranslationData.pages[string]` @@ -552,7 +556,7 @@ Per-app translation data for objects, apps, and UI messages | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -569,7 +573,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -628,6 +632,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `TranslationData.pages[string]` @@ -729,7 +734,7 @@ One locale of translations — the `translation` metadata type | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | @@ -756,7 +761,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -815,6 +820,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `TranslationItem.pages[string]`