Skip to content

Commit 39a78b1

Browse files
feat(spec)!: a locale must be a BCP-47 tag — zh_CN, padded values and language names are refused (#25983)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbDHE1kjcgpLAAwZU2cx9frG)_ Fixes #25822 Clause-②: no (narrowing) Metadata-protocol: narrowed ## What changes `LocaleSchema` (`packages/spec/src/system/translation.zod.ts`) was a bare `z.string()`. A translator or AI author who wrote `zh_CN`, a padded value or `not a locale!!` got a green save, and the strings were stored under a locale no session ever asks for. The resolvers fold case and nothing else, so those strings never rendered. - **Schema refusal.** `LocaleSchema` now accepts only a tag-shaped value: a 2–8 letter language subtag, then hyphen-separated 1–8 letter-or-digit subtags. This is the same pattern `SYS_USER_LOCALE_TAG_PATTERN` uses for a user's own `locale` column. The refusal is one `invalid_format` issue whose message names the shape and the release. Case is not judged: `zh-cn` still parses, because every resolver folds case. - **Data at rest.** ADR-0087 D2 conversion `translation-locale-tag-canonicalized` (retired from the load path from day one, `retiredAfter: '18.1.0'`, order 76) rewrites the two spellings that have one tag they can have meant: `_` as the separator, and surrounding whitespace. Any other value is left as stored and reported as a TODO. So is a bundle key that would land on a tag the entry already holds with different strings. An identical twin is merged. - **D3 entry** `translation-locale-non-tag-refused` carries the judgment for `os migrate meta`. - **Changeset** `.changeset/25822-locale-tag-shape.md` (`@objectstack/spec` minor) with the FROM → TO mapping and both declaration lines. ## Every consumer of `LocaleSchema`, and whether it is in scope | Site | In scope? | What happens | |---|---|---| | `TranslationItemSchema.locale` (metadata save door, `defineTranslation`) | yes | refused at `locale`; the card's reproduction | | `TranslationBundleSchema` / `PlatformTranslationBundleSchema` map key (`defineTranslationBundle`, `stack.translations`) | yes | refused as `invalid_key` at the key; the error map surfaces the nested message | | `TranslationConfigSchema` `defaultLocale` / `supportedLocales` / `fallbackLocale` | yes | refused at the value; the conversion covers `i18n.*` | | `data/seed.zod.ts` `Seed.locale[]` | yes | refused at the value; the conversion covers `data[].locale[]` | | `data/seed-loader.zod.ts` `SeedLoaderConfig.locale` | yes, by reach | a runtime request, not stored. Its one producer is `AppPlugin.resolveSeedLocale()`, which reads `i18n.defaultLocale`, itself narrowed above, and converted at artifact load (`convertArtifactAtLoad` runs over the definition) | | `conversions/registry.ts` comments at the submitLabel and subCaption walks ("`LocaleSchema` is an open string") | no edit | still true in the sense they use it: the locale keys are an open set and cannot be enumerated, so judging the bundle structurally stays right | | `packages/lint/src/lint-liveness-properties.ts` translation bundle walk | no edit | walks every key under the never-throws contract; unaffected by the shape | | `ui/packages/app-shell/.../metadata-admin/clientValidation.ts` | no edit | its comment describes the bundle-vs-item contract, not the locale shape; the server door refuses, which is the contract | ## Mechanism assumptions, re-measured on `0c372288` - **Zero non-canonical locales in non-test source**: holds. A `git grep` over `locale`/`defaultLocale`/`fallbackLocale` literals, `supportedLocales` arrays and underscore bundle keys across `packages/` and `examples/` finds none that parse through `LocaleSchema`. The non-tag hits are a user's `sys_user.locale`, an email-template locale and the i18n label resolver's inputs, none of which use this schema. - **Syntax-only check fits**: holds (`shared/value-domain.zod.ts` records why a registry check was dropped). - **Stored deployed rows**: cannot be measured from the repository. The conversion covers stored `translation` rows (`applyConversionsToStoredItem` reaches them through `STORED_ONLY_COLLECTIONS`) and built artifacts inside their declared-floor window. - **Landing point**: as ordered, `translation.zod.ts` plus one MAJOR_18 conversion. No other producer needed a fix. ## Deviations from the claim's file surface Two files outside `translation.zod.ts (+ tests), registry.ts, generated artefacts, one changeset`, each forced by a red test: - `packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts`, the D3 entry: `migrations.test.ts` reds "from protocol 18 on, every graduated D2 conversion is named by a D3 entry of its own step (ruling B)" without it (measured). - `packages/spec/src/shared/retired-key-removal-version.pin.test.ts`: one JOIN row. The pin reds every entry retired after the newest published stable release that has no row. ## Verification (head `3e367787`) - `pnpm --filter @objectstack/spec build`, then `check:generated`: 14/14 artifacts up to date. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `pnpm --filter @objectstack/spec test`: 722 files, 21903 tests passed. - New pins in `translation.test.ts`: tags accepted, ten non-tags refused as `invalid_format` naming the release, refusal at the item, bundle key, platform bundle key and i18n config, and the conversion's TODOs and twin merge. The conversion fixture runs in `conversions.test.ts` (7 notices). - Derived local gates (`dispatch-gates.mjs --commands`): 92/92 run, all exit 0. Five exited 3 (prerequisite not built) on the first pass and 0 after the dependency closure was built. `--ran` reconciliation: 92 derived, 92 run, 0 NOT-MEASURED. - Dogfood battery (`pnpm --filter @objectstack/dogfood test` under the verify lock, after building the dogfood closure): VERDICT command-exit 1. Test Files 1 failed, 295 passed, 3 skipped (299); Tests 1 failed, 2522 passed, 18 skipped (2541). The one failure is not this diff's: `test/authz-probe-blind-spot.test.ts` > `packages/runtime/src/domains/i18n.ts` expects 3 occurrences of the `handleI18nRequest` control and reads 4. `69fa23d60` (#25956, already on `main`) added the fourth. This diff touches no runtime file. ## Acceptance notes - Case-insensitive acceptance (`zh-cn`) is deliberate: the resolvers fold case (`resolveLocale` in core's memory i18n, the metadata-protocol seed loader), so refusing it would refuse a locale that works. - A non-tag `i18n.defaultLocale` that reaches `AppPlugin` without passing `defineStack` and outside the artifact window now fails the boot-time `SeedLoaderRequestSchema.parse` loudly instead of filtering seeds on a locale no dataset can name. --- _Generated by [Claude Code](https://claude.ai/code/session_01Do245n6wxzetD6poZNSdeM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c16c00f commit 39a78b1

8 files changed

Lines changed: 369 additions & 5 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a locale must be a BCP-47 tag — `zh_CN`, a padded value or a language name is refused
6+
7+
`LocaleSchema` was a bare string, so a translator or AI author who wrote `zh_CN`, `'zh-CN '` or `not a locale!!` got a green save (`PUT /api/v1/meta/translation/:name` answered 200 with `_diagnostics.valid: true`). The strings were stored under a locale no session ever asks for. The resolvers fold case and nothing else, so those strings never rendered, with no diagnostic.
8+
9+
`LocaleSchema` now accepts a tag-shaped value only: a 2–8 letter language subtag, then hyphen-separated 1–8 letter-or-digit subtags (`en`, `zh-CN`, `zh-Hans-CN`, `es-419`). It is the same shape a user's own `locale` column accepts. Case is not judged (`zh-cn` still parses, and resolves). The check applies everywhere the schema is used:
10+
11+
- a `translation` item's `locale` (the metadata save door and `defineTranslation`);
12+
- a translation bundle's locale keys (`defineTranslationBundle`, `stack.translations`, the platform bundle);
13+
- `i18n.defaultLocale`, `i18n.fallbackLocale` and `i18n.supportedLocales`;
14+
- a seed dataset's `locale` scope and the seed loader's `locale`.
15+
16+
Stored rows and older built artifacts: the ADR-0087 D2 conversion `translation-locale-tag-canonicalized` rewrites the two spellings that have one tag they can have meant: `_` as the separator (`zh_CN` → `zh-CN`) and surrounding whitespace. A stored `translation` row is rewritten when it is rehydrated, so its strings start rendering for the locale they were written for. Any other value is left as stored and reported as a TODO by `os migrate meta --stored`. So is a bundle key that would land on a tag the entry already holds with different strings. The conversion is retired from the load path, so a source that still writes a non-tag locale is refused, not rewritten. The D3 entry `translation-locale-non-tag-refused` carries the judgment for `os migrate meta`.
17+
18+
Clause-②: no (narrowing) — `LocaleSchema` refuses values it used to accept. Nothing is widened.
19+
20+
Metadata-protocol: narrowed (a locale that is not a BCP-47 tag is refused on translation items, bundle keys, the i18n configuration and seed datasets)
21+
22+
<!-- adr-0087: registered translation-locale-non-tag-refused, translation-locale-tag-canonicalized -->
23+
24+
**BREAKING** for an author who wrote a non-tag locale.
25+
26+
- **Before.** `defineTranslation({ name: 'zh_cn', locale: 'zh_CN', … })` parsed, and its strings never rendered.
27+
- **Now.** The value is refused at `locale` (or at the bundle key) with a message naming the tag shape.
28+
- **Fix.** Write the tag: `zh_CN` → `zh-CN`, drop surrounding whitespace, and replace a language name with its tag. `os migrate meta --from 17 --write` applies the first two to your sources.

‎content/docs/protocol/kernel/i18n-standard.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,13 @@ are retired and read by nothing: the `o.{object}` shape below, and the flat
240240
`<namespace>.<prop>` shape recorded under
241241
[ObjectQL Integration](#objectql-integration).
242242

243+
Every locale code (a bundle key, an item's `locale`, `i18n.defaultLocale`,
244+
`supportedLocales` and `fallbackLocale`) is a BCP-47 language tag with `-`
245+
between subtags (`en`, `zh-CN`, `zh-Hans-CN`). `LocaleSchema` refuses anything
246+
else at parse — `zh_CN`, a padded value, a language name — and the 18.x
247+
conversion `translation-locale-tag-canonicalized` rewrites the `_` and padded
248+
spellings in stored rows and older builds. Case is not judged: the resolvers fold it.
249+
243250
```typescript
244251
// One locale of a TranslationBundle (e.g. zh-CN)
245252
const zh: TranslationData = {

‎content/docs/ui/translations.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ labels either way.
1818

1919
## Your first bundle
2020

21-
A bundle is a **map of locale → translations**, registered on your stack:
21+
A bundle is a **map of locale → translations**, registered on your stack. Every
22+
locale (a bundle key, an item's `locale`, the `i18n` locales) must be a BCP-47
23+
language tag with `-` between subtags (`en`, `zh-CN`, `zh-Hans-CN`); `zh_CN`, a
24+
padded value or a language name is refused at parse, and the 18.x conversion
25+
rewrites the `_` and padded spellings in stored rows and older builds:
2226

2327
```typescript title="src/translations/crm.translation.ts"
2428
import { defineTranslationBundle } from '@objectstack/spec';

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 189 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { misplacedDefaultPageRefusal } from '../ui/default-record-page.js';
4949
import { FLOW_REGION_SLOTS_BY_TYPE } from '../automation/region-slots.js';
5050
import { CONNECTOR_CONFIG_LOOSE_KEYS, WAIT_EVENT_CONFIG_LOOSE_KEYS } from '../automation/flow-sibling-block-loose-keys.js';
5151
import { FLOW_RESERVED_NODE_TYPES } from '../automation/flow-reserved-node-types.js';
52+
import { LocaleSchema } from '../system/translation.zod.js';
5253

5354
/**
5455
* Flow callout node type rename (protocol 11.0; retired from the load path at
@@ -8204,6 +8205,188 @@ const elementFormRemoved: MetadataConversion = {
82048205
},
82058206
};
82068207

8208+
/**
8209+
* A locale written with `_` separators or surrounding whitespace becomes the
8210+
* BCP-47 tag it meant (protocol 18, #25822): `zh_CN` → `zh-CN`, `'en '` → `en`.
8211+
*
8212+
* `LocaleSchema` was a bare string, so a translation item, a bundle's locale
8213+
* key, the stack's `i18n` locales and a seed dataset's `locale` scope all
8214+
* saved any text. The resolvers fold case and nothing else, so a value
8215+
* spelled with `_` or padded with whitespace named a locale no session ever
8216+
* asks for: its strings were stored and never rendered. The schema now
8217+
* refuses anything that is not tag-shaped.
8218+
*
8219+
* **What it rewrites.** Only the two spellings that have exactly one tag they
8220+
* can have meant: `_` used as the subtag separator, and whitespace around the
8221+
* value. Case is kept as written, because every resolver already folds it.
8222+
* The result must pass `LocaleSchema`; a value that still does not (a
8223+
* language name, `not a locale!!`) is left as stored and reported as a TODO,
8224+
* because only its author knows which locale it was for.
8225+
*
8226+
* **A rewrite changes what renders, on purpose.** A `zh_CN` item's strings
8227+
* start resolving for `zh-CN` sessions, the strings their author wrote them
8228+
* for. On a bundle a rename that would land on a locale key the entry already
8229+
* holds is merged only when the two values are identical (`renameKey`);
8230+
* otherwise both are left as stored and reported, because picking a winner
8231+
* would silently discard one translator's strings.
8232+
*
8233+
* **Reach.** `translations[]`: a bare item's `locale` (the shape stored
8234+
* `translation` rows replay through), or a bundle entry's locale keys. A
8235+
* bundle key is judged only when it carries something other than letters, so
8236+
* a legacy item written before `locale` was required, whose top-level keys
8237+
* are group names (`globalActions`, `dashboards`), is never misread as a
8238+
* bundle with bad locale keys. Also `i18n.defaultLocale`,
8239+
* `i18n.fallbackLocale`, `i18n.supportedLocales[]` and `data[].locale[]`.
8240+
*
8241+
* Retired from the load path from day one: a live author meets the parse's
8242+
* refusal and its prescription instead of being rewritten. Stored rows
8243+
* (`applyConversionsToStoredItem`) and a built artifact inside its
8244+
* declared-floor window get the rewrite. Idempotent: a tag-shaped value is
8245+
* never touched again.
8246+
*/
8247+
const translationLocaleTagCanonicalized: MetadataConversion = {
8248+
id: 'translation-locale-tag-canonicalized',
8249+
toMajor: 18,
8250+
retiredFromLoadPath: true,
8251+
retiredAfter: '18.1.0',
8252+
surface:
8253+
'translation.locale / stack.translations[].<locale> / i18n.defaultLocale / i18n.fallbackLocale / '
8254+
+ 'i18n.supportedLocales[] / data[].locale[]',
8255+
summary:
8256+
"a locale written with '_' separators or surrounding whitespace → the BCP-47 tag it meant "
8257+
+ "('zh_CN' → 'zh-CN'): no resolver matched it, so its strings never rendered; a value that is "
8258+
+ 'still not tag-shaped is left as stored and reported',
8259+
apply(stack, emit, context) {
8260+
const isTag = (v: string): boolean => LocaleSchema.safeParse(v).success;
8261+
/** The one tag `raw` can have meant, or `undefined` when it is a tag already or names none. */
8262+
const canonical = (raw: string): string | undefined => {
8263+
if (isTag(raw)) return undefined;
8264+
const tag = raw.trim().replace(/_/g, '-');
8265+
return isTag(tag) ? tag : undefined;
8266+
};
8267+
const todo = (path: string, from: string, reason: string): void =>
8268+
context?.reportTodo?.({ path, from: JSON.stringify(from), reason });
8269+
const NOT_A_TAG =
8270+
'is not a BCP-47 language tag and has no single tag it can have meant. Left as stored, it is '
8271+
+ 'refused where it is parsed. Rewrite it by hand as the tag its strings were written for '
8272+
+ '(`en`, `zh-CN`, `zh-Hans-CN`).';
8273+
/** A locale value: rewritten, reported, or (already a tag) returned as is. */
8274+
const value = (raw: string, path: string, subject: string): string => {
8275+
if (isTag(raw)) return raw;
8276+
const tag = canonical(raw);
8277+
if (tag === undefined) {
8278+
todo(path, raw, `${subject} ${NOT_A_TAG}`);
8279+
return raw;
8280+
}
8281+
emit({ from: JSON.stringify(raw), to: JSON.stringify(tag), path });
8282+
return tag;
8283+
};
8284+
const list = (raw: unknown, path: string, subject: string): unknown => {
8285+
if (!Array.isArray(raw)) return raw;
8286+
let changed = false;
8287+
const next = raw.map((v, i) => {
8288+
if (typeof v !== 'string') return v;
8289+
const mapped = value(v, `${path}[${i}]`, subject);
8290+
if (mapped !== v) changed = true;
8291+
return mapped;
8292+
});
8293+
return changed ? next : raw;
8294+
};
8295+
8296+
let next = mapCollection(stack, 'translations', (entry, path) => {
8297+
// A bare `translation` ITEM: its own `locale`.
8298+
if ('locale' in entry) {
8299+
if (typeof entry.locale !== 'string') return entry;
8300+
const locale = value(entry.locale, `${path}.locale`, 'The translation item locale');
8301+
return locale === entry.locale ? entry : { ...entry, locale };
8302+
}
8303+
// A bundle entry: locale code → data.
8304+
let bundle = entry;
8305+
for (const [key, data] of Object.entries(entry)) {
8306+
if (!isDict(data) || /^[A-Za-z]+$/.test(key) || isTag(key)) continue;
8307+
const tag = canonical(key);
8308+
if (tag === undefined) {
8309+
todo(`${path}.${key}`, key, `The translation bundle locale key ${NOT_A_TAG}`);
8310+
continue;
8311+
}
8312+
const renamed = renameKey(bundle, key, tag);
8313+
if (renamed === null) {
8314+
todo(
8315+
`${path}.${key}`,
8316+
key,
8317+
`The translation bundle locale key means \`${tag}\`, and the entry already holds different `
8318+
+ `strings under \`${tag}\`. Left as stored, it is refused where it is parsed. Merge the two `
8319+
+ 'by hand and keep one key.',
8320+
);
8321+
continue;
8322+
}
8323+
emit({ from: JSON.stringify(key), to: JSON.stringify(tag), path: `${path}.${key}` });
8324+
bundle = renamed;
8325+
}
8326+
return bundle;
8327+
});
8328+
8329+
const i18n = next.i18n;
8330+
if (isDict(i18n)) {
8331+
let config = i18n;
8332+
for (const key of ['defaultLocale', 'fallbackLocale'] as const) {
8333+
const raw = config[key];
8334+
if (typeof raw !== 'string') continue;
8335+
const mapped = value(raw, `i18n.${key}`, `i18n.${key}`);
8336+
if (mapped !== raw) config = { ...config, [key]: mapped };
8337+
}
8338+
const supported = list(config.supportedLocales, 'i18n.supportedLocales', 'A supported locale');
8339+
if (supported !== config.supportedLocales) config = { ...config, supportedLocales: supported };
8340+
if (config !== i18n) next = { ...next, i18n: config };
8341+
}
8342+
8343+
return mapCollection(next, 'data', (dataset, path) => {
8344+
const locale = list(dataset.locale, `${path}.locale`, 'A seed dataset locale');
8345+
return locale === dataset.locale ? dataset : { ...dataset, locale };
8346+
});
8347+
},
8348+
fixture: {
8349+
before: {
8350+
translations: [
8351+
// A stored item under an underscore spelling.
8352+
{ name: 'zh_cn', locale: 'zh_CN', messages: { commonSave: '保存' } },
8353+
// Already a tag, in lower case: resolvers fold case, so it is left alone.
8354+
{ name: 'pt_br', locale: 'pt-br', messages: { commonSave: 'Salvar' } },
8355+
// Not a tag and no single tag it can have meant: a TODO, left as stored.
8356+
{ name: 'qa_bad_locale', locale: 'not a locale!!', messages: { commonSave: 'x' } },
8357+
// A bundle entry: one padded key, one underscore key, one tag.
8358+
{
8359+
'en ': { messages: { commonSave: 'Save' } },
8360+
ja_JP: { messages: { commonSave: '保存' } },
8361+
'zh-CN': { messages: { commonSave: '保存' } },
8362+
},
8363+
// A legacy item without `locale`: its group keys are not locale keys.
8364+
{ name: 'fr', globalActions: { export_all: { label: 'Tout exporter' } } },
8365+
],
8366+
i18n: { defaultLocale: 'en_US', supportedLocales: ['en_US', 'zh-CN', ' ja-JP'], fallbackLocale: 'en' },
8367+
data: [{ object: 'crm_lead', locale: ['zh_CN'], records: [] }],
8368+
},
8369+
after: {
8370+
translations: [
8371+
{ name: 'zh_cn', locale: 'zh-CN', messages: { commonSave: '保存' } },
8372+
{ name: 'pt_br', locale: 'pt-br', messages: { commonSave: 'Salvar' } },
8373+
{ name: 'qa_bad_locale', locale: 'not a locale!!', messages: { commonSave: 'x' } },
8374+
{
8375+
'zh-CN': { messages: { commonSave: '保存' } },
8376+
en: { messages: { commonSave: 'Save' } },
8377+
'ja-JP': { messages: { commonSave: '保存' } },
8378+
},
8379+
{ name: 'fr', globalActions: { export_all: { label: 'Tout exporter' } } },
8380+
],
8381+
i18n: { defaultLocale: 'en-US', supportedLocales: ['en-US', 'zh-CN', 'ja-JP'], fallbackLocale: 'en' },
8382+
data: [{ object: 'crm_lead', locale: ['zh-CN'], records: [] }],
8383+
},
8384+
// One per rewritten value: the item, two bundle keys, the default locale,
8385+
// two supported locales and the seed scope.
8386+
expectedNotices: 7,
8387+
},
8388+
};
8389+
82078390
/**
82088391
* `settings` on every APPLICATION-authored translation face — the platform-only
82098392
* group leaving `stack.translations` with the type split (protocol 18, #15178,
@@ -8560,8 +8743,9 @@ const translationComponentSubmitLabelRemoved: MetadataConversion = {
85608743
// Bare data/item shape: the groups sit at the entry's top level.
85618744
let next = stripFromData(entry, path);
85628745
// Bundle shape: locale code → data. Judged structurally (a dict whose
8563-
// `pages` is a dict) rather than by key spelling — `LocaleSchema` is an
8564-
// open string, so the locale keys cannot be enumerated. A strip-only
8746+
// `pages` is a dict) rather than by key spelling — `LocaleSchema` judges
8747+
// only a tag's shape, an open-ended set, so the locale keys cannot be
8748+
// enumerated. A strip-only
85658749
// walk makes a false positive a no-op: it removes nothing unless the
85668750
// exact `pages.<name>.components.<id>.submitLabel` path is present.
85678751
for (const [locale, data] of Object.entries(next)) {
@@ -14220,7 +14404,8 @@ const translationWidgetSubCaptionRemoved: MetadataConversion = {
1422014404
// Bare data/item shape: the groups sit at the entry's top level.
1422114405
let next = stripFromData(entry, path);
1422214406
// Bundle shape: locale code → data, judged structurally (a dict whose
14223-
// `dashboards` is a dict) because `LocaleSchema` is an open string. A
14407+
// `dashboards` is a dict) because `LocaleSchema` judges only a tag's
14408+
// shape, an open-ended set the locale keys cannot be enumerated from. A
1422414409
// strip-only walk makes a false positive a no-op: it removes nothing
1422514410
// unless the exact `dashboards.<name>.widgets.<id>.subCaption` path is
1422614411
// present.
@@ -18215,6 +18400,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [
1821518400
{ conversion: reportJoinedChartRemoved, order: 38 },
1821618401
{ conversion: timeDefaultUtcSuffixDropped, order: 48 },
1821718402
{ conversion: translationComponentSubmitLabelRemoved, order: 12 },
18403+
{ conversion: translationLocaleTagCanonicalized, order: 76 },
1821818404
{ conversion: translationPerAppSettingsRemoved, order: 34 },
1821918405
{ conversion: translationWidgetSubCaptionRemoved, order: 55 },
1822018406
{ conversion: tursoConfigEncryptionKeyRemoved, order: 74 },
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// The D3 entry of the `translation-locale-tag-canonicalized` family: the D2
6+
// conversion rewrites the two spellings that have one tag they can have meant
7+
// (an `_` separator, surrounding whitespace) in stored rows and built
8+
// artifacts; this entry is the judgment an author still owes on the rest.
9+
export const entry: SemanticMigration = {
10+
id: 'translation-locale-non-tag-refused',
11+
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a
12+
// code span already, and a nested backtick would close it.
13+
surface:
14+
'a locale that is not a BCP-47 tag (LocaleSchema): a translation item locale, a translation '
15+
+ 'bundle locale key, i18n.defaultLocale / fallbackLocale / supportedLocales, a seed dataset '
16+
+ 'locale scope, the seed loader locale',
17+
replacement:
18+
'the BCP-47 tag the value was written for: a 2-8 letter language subtag, then hyphen-separated '
19+
+ '1-8 letter-or-digit subtags (`en`, `zh-CN`, `zh-Hans-CN`, `es-419`). Write `zh-CN` for '
20+
+ '`zh_CN` and drop surrounding whitespace; the conversion does both for stored rows and built '
21+
+ 'artifacts. A language name or any other text has no single tag it can have meant, so rewrite '
22+
+ 'it by hand. Case is not judged: `zh-cn` is accepted, because the resolvers fold case',
23+
reason:
24+
'LocaleSchema was a bare string, so a translator or AI author who wrote `zh_CN`, a padded '
25+
+ 'value or a language name got a green save, and the strings were stored under a locale no '
26+
+ 'session ever asks for. The resolvers fold case and nothing else, so those strings never '
27+
+ 'rendered, with no diagnostic: indistinguishable from a translation nobody had written yet. '
28+
+ 'The schema now refuses any value that is not tag-shaped, at the metadata save door, at '
29+
+ 'defineTranslation / defineTranslationBundle and on the bundle map key. The shape is the one '
30+
+ 'a user locale column already accepts. It is a syntax check only: membership has no registry '
31+
+ 'to check against. Ships at once, no grace window and no dual spelling.',
32+
acceptanceCriteria:
33+
'Every translation item locale, translation bundle key, i18n locale and seed dataset locale '
34+
+ 'parses through LocaleSchema. A stored `translation` row under `zh_CN` or a padded locale '
35+
+ 'loads with the tag rewritten and its strings start rendering for that locale. A stored row '
36+
+ 'under any other non-tag value keeps loading as stored, is listed by `os migrate meta --stored` '
37+
+ 'as a TODO, and fails the schema wherever it is parsed until it is rewritten. A bundle whose '
38+
+ 'underscore key would land on a tag it already holds with different strings is reported and '
39+
+ 'left for the author to merge.',
40+
conversionIds: ['translation-locale-tag-canonicalized'],
41+
};

‎packages/spec/src/shared/retired-key-removal-version.pin.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import { PageComponentSchema, PageSchema } from '../ui/page.zod';
9494
import { AddRecordConfigSchema, FormFieldSchema, FormSectionSchema, ListViewSchema, ObjectListViewSchema } from '../ui/view.zod';
9595
import { FlowSchema } from '../automation/flow.zod';
9696
import { JobSchema } from '../system/job.zod';
97+
import { TranslationItemSchema } from '../system/translation.zod';
9798
import { PROTOCOL_MAJOR } from '../kernel/protocol-version';
9899
import { EmailTemplateDefinitionSchema } from '../system/email-template.zod';
99100
import {
@@ -260,6 +261,13 @@ const JOIN: Readonly<Record<string, readonly Renderer[]>> = {
260261
'objects',
261262
),
262263
],
264+
'translation-locale-tag-canonicalized': [
265+
refusal(
266+
"a translation item's locale written with '_'",
267+
() => TranslationItemSchema.safeParse({ name: 'zh_cn', locale: 'zh_CN', messages: { commonSave: '保存' } }),
268+
'locale',
269+
),
270+
],
263271
'manifest-permissions-string-list-removed': [
264272
refusal('manifest.permissions as a list of strings', () => ManifestPermissionsSchema.safeParse(['system.user.read'])),
265273
],

0 commit comments

Comments
 (0)