Skip to content

Commit 86d0a8c

Browse files
feat(spec)!: a locale tag written in another case is corrected to its BCP-47 case (#29833)
domain:spec#2 · os-support-ai · session_01WGufzhJG3tKnksHVa1krBz <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBY61hHdLdFdzwXbxaNtv58y)_ Fixes #28993 Supersedes #29190 (same commits, resumed on a new branch) Ruling-ref: 6595806433 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: updated here — `content/docs/protocol/kernel/i18n-standard.mdx` (the "Case is not judged: the resolvers fold it" sentence now says case is corrected on parse, a stored row's locale is folded by the conversion `translation-locale-tag-case-folded`, and a bundle with two keys that fold to one tag is refused). `skills/objectstack-i18n/SKILL.md` already spells every tag in its canonical case and teaches no case rule, so no skill text or eval moves. ## 维护者速读 - 改了什么:区域标签(locale)大小写写得不标准时,解析会改成 BCP-47 标准写法(`zh-cn` → `zh-CN`,`zh-hans-cn` → `zh-Hans-CN`),只改大小写,不改别的;库里已经按 `zh-cn` 存下的翻译,读出时由一个新的存量转换(`translation-locale-tag-case-folded`)按标准写法归位。同一个翻译包里同时写了 `zh-cn` 和 `zh-CN` 两个键,会被拒绝并点名两个键。 - 为什么改:翻译以 `zh-cn` 保存时接口回 200,但 `zh-CN` 会话永远看不到这些翻译(#28993),因为语言解析先精确匹配,内置的 `zh-CN` 包总是先命中。现在解析层只留一种写法,每个读者看到同一个标签。 - 风险与回滚:这是一处破坏性收窄(BREAKING),维护者已按 A 裁定(拒绝,裁决 6595806433):同一个翻译包里只差大小写的两个键(如 `zh-cn` 和 `zh-CN`)以前能通过,现在会被拒绝并点名两个键,仓内命中 0 处。大小写更正本身是放宽,其它原来能通过的输入照样通过,JSON Schema 不变。还有两处按原写法精确比较的地方(邮件模板的语言下限告警、通知模板的语言梯):配置里写成 `zh-cn` 的默认或支持语言现在读作 `zh-CN`,存量模板行若仍标 `zh-cn` 就对不上,需要把模板行改成标准写法,行侧归位随 server 车道的入口卡落地。备好的「合并」替代方案不采用。保存入口仍原样存 `zh-cn`(入口属 server 车道,另起一张卡),但读出已归位,不影响本卡效果。回滚即撤掉本 PR。 - 先看:`packages/spec/src/system/translation.zod.ts`(`canonicalLocaleCase` 与两个翻译包 schema 上的 `superRefine` / `overwrite`);`packages/spec/src/conversions/registry.ts` 新增的 `translation-locale-tag-case-folded`(存量行大小写归位,排在 #25822 的 `translation-locale-tag-canonicalized` 之后);`packages/spec/src/system/translation.test.ts` 新增的一组测试。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/system/translation.test.ts`,应全部通过,其中包含新增的「LocaleSchema corrects a tag to its BCP-47 case (#28993)」一组。 - 需要你做的:无,席位落地。 ## What changes - **`LocaleSchema` corrects case** (`packages/spec/src/system/translation.zod.ts`). A tag that passes `LOCALE_TAG_SHAPE` is returned in its BCP-47 case (RFC 5646 §2.1.1): the language subtag lower case, a 4-letter script title case, a 2-letter region upper case, any other subtag lower case, and every subtag after a singleton lower case. The fold is `.overwrite(canonicalLocaleCase)`, so `z.input` and `z.output` are both `string`, `Locale` stays `z.input`, and the published JSON Schema does not move (`check:generated` regenerated nothing). A canonical tag is returned unchanged, and nothing that parsed before is refused except the bundle case below. - **Case only, not `Intl.getCanonicalLocales`.** Measured on Node v22.23.4: `Intl.getCanonicalLocales` rewrites more than case (`iw` → `he`, `in` → `id`, `tl` → `fil`, `cmn` → `zh`, `mo` → `ro`, `art-lojban` → `jbo`, `en-us-POSIX` → `en-US-u-va-posix`) and throws on tags `LOCALE_TAG_SHAPE` accepts (`zh-Hans-Hans`, `sgn-BE-FR`, `en-GB-oed`, `ar-aao`, `en-a-b`). Replacing a legacy subtag would move a translation to a locale its author never named, and the answer would depend on the runtime's ICU data. - **Readers whose output changes:** a translation item's `locale`; `TranslationBundle` / `PlatformTranslationBundle` keys; `i18n.defaultLocale`, `fallbackLocale`, `supportedLocales`; a seed dataset's `locale` scope (`data/seed.zod.ts`) and the seed loader's `locale` (`data/seed-loader.zod.ts`). `EmailTemplateDefinitionSchema.locale` does not use `LocaleSchema` and is untouched (#28896 / #29014 unaffected); `PackageLocaleSchema` (marketplace) is a separate schema and is untouched. - **Bundle keys fold, and two keys that fold to one tag are refused** (the narrowing, BREAKING; see Acceptance notes). Both bundle schemas are now `z.record(LocaleTagKeySchema, …).superRefine(refuseLocaleKeysFoldingTogether).overwrite(foldLocaleKeys)`. The key schema is a shape-only twin of `LocaleSchema` (same regex, message and description, no fold): a folding key schema would let `z.record` keep the later of two colliding keys and drop the other with no issue. The refinement runs before the fold and refuses `{ 'zh-cn': …, 'zh-CN': … }` with a `custom` issue at the second key, naming both keys, the tag and the release (`Refused since @objectstack/spec 18`). The fold then rebuilds the record with canonical keys, in order, and returns the input itself when every key is already canonical. - **Stored rows read back canonical, through a D2 entry of their own** (`packages/spec/src/conversions/registry.ts`). The new ADR-0087 D2 conversion `translation-locale-tag-case-folded` (`toMajor: 18`, `retiredFromLoadPath: true`, `retiredAfter: '18.2.0'`, the same surface as #25822's entry) folds a tag in another case to the `LocaleSchema` parse: an item's `locale`, `i18n.defaultLocale` / `fallbackLocale` / `supportedLocales`, `data[].locale[]` and bundle keys, one notice each. It folds tag-shaped values only and leaves the rest to the entry before it. A bundle key that folds onto a key already holding different strings is left as stored and reported as a TODO (naming the release); an identical twin is merged by `renameKey`. Its `order` is 76.5, right after #25822's `translation-locale-tag-canonicalized` (76), so an underscore spelling is made a tag first and folded second (`zh_cn` → `zh-cn` → `zh-CN`). - **#25822's entry keeps its own stamp.** `translation-locale-tag-canonicalized` stays `retiredAfter: '18.1.0'` with its code unchanged: its docblock now says case is kept as written there and points at the case entry, retired a release later, because the premise that every resolver folds case was measured false. Its fixture moves only to stay disjoint (see Forced test edits). - **Why stored rows need it.** The save door (`saveMetaItem`) stores the authored body, so a `zh-cn` save stays `zh-cn` at rest; core's `readAuthoredTranslationLayer` replays the full conversion chain over each stored row before keying it by `data.locale`, so the row reaches `zh-CN` sessions from the next sync on, with nothing to re-save. An artifact built by `@objectstack/spec` 18.2.0 or earlier is folded the same way inside its declared-floor window. - **Exact matchers that read a folded locale against a row tagged as written.** Two consumers compare a folded i18n locale with a row whose locale is not folded: the email-template floor warning (`warnEmailTemplateLocaleFloor` in `stack.zod.ts`) compares `i18n.supportedLocales` with `emailTemplates[].locale`, and the messaging template ladder compares `localeCandidates(i18n.getDefaultLocale())` with `sys_notification_template.locale`. A row tagged `zh-cn` no longer matches a supported or default locale authored as `zh-cn`, which now reads `zh-CN`. The D3 entry's replacement and acceptance criteria name both: the consumer folds case or the row is re-tagged, and the row-side fold lands with the server sibling card (the save door storing the parsed locale). Not changed here. - **Upgrade path:** new ADR-0087 D3 semantic entry `packages/spec/src/migrations/entries/semantic/18.translation-locale-case-canonicalized.ts`, `conversionIds: ['translation-locale-tag-case-folded']`. The sibling entry `18.translation-locale-non-tag-refused.ts` said case is not refused because the resolvers fold it; it now says the parse corrects it. - **Docs:** `content/docs/protocol/kernel/i18n-standard.mdx`, one paragraph (see `Skills-docs:`). This file is outside the claim's listed surface: a bounded in-place fix of a sentence this diff makes false. - **Dropped-refinements ledger** (`packages/spec/dropped-refinements.baseline.json`): +6 sites: 1012 → 1018 on the merged base `b3d14150` (983 → 989 on the original base `6b3ba82c`; see Verification for the merge's one conflict, in this file). The bundle `superRefine` has no JSON Schema form, so it lands at the root of `system/TranslationBundle` and `system/PlatformTranslationBundle`, and at `manifest.translations.element` in the four `api/*InstalledPackage*` responses that embed a bundle. Entries are the ones the spec build's gate printed. - **Changeset:** `.changeset/28993-locale-case-canonicalized.md`, `@objectstack/spec: minor`, titled `feat(spec)!:`. It carries the two declaration lines above, a **BREAKING** banner for the refusal with its migration (FROM a bundle holding both spellings, `{ 'zh-cn': A, 'zh-CN': B }`, TO one canonical key holding both sets of strings, merged by hand), and one ADR-0087 disposition, `registered translation-locale-case-canonicalized` (the D3 entry is new in this diff and prescribes that merge). Its stored-rows bullet names the new conversion, and a bullet names the two exact matchers. - **Not changed, on purpose:** core `memory-i18n.ts#resolveLocale` (triage 6570179836: no third runtime case-fold); the save door in `packages/metadata-protocol` (domain:server, handed off as a sibling card); the two exact matchers above. ### Forced test edits - `packages/spec/src/conversions/registry.ts`, the fixture of #25822's `translation-locale-tag-canonicalized`: its stored item `locale: 'pt-br'` is now `pt-BR` on both sides (`before` and `after`), `expectedNotices` unchanged at 7. The fixture runner replays the whole chain, so a `pt-br` there would now be folded by the case entry and give one notice that is not this entry's; fixtures stay disjoint. The case belongs to the new entry's fixture. - `packages/spec/src/shared/retired-key-removal-version.pin.test.ts`: `translation-locale-tag-case-folded` is retired after 18.2.0, the newest published stable release, so the census pin requires a `JOIN` row with the prescriptions an author meets. A lone tag is corrected, not refused, so the row reads the collision two keys of one bundle make once folded: the bundle refusal at the parse, and the conversion's TODO for a stored pair (a new `todo` row kind, for a data-at-rest prescription that has no parse refusal to read). Both messages now name `@objectstack/spec 18`, which the row derives from the stamp. - `packages/spec/src/system/translation.test.ts`: the existing `accepts the tag %j (case is not judged)` row is retitled `(case is corrected, never refused)`. Its assertion is unchanged; the old title stated the rule this PR reverses. ## Tests `packages/spec/src/system/translation.test.ts`, new describe `LocaleSchema corrects a tag to its BCP-47 case (#28993)`: - Fold table: `zh-cn`, `ZH-CN` → `zh-CN`; `zh-hans-cn`, `ZH-HANS-cn` → `zh-Hans-CN`; `EN` → `en`; `en-us` → `en-US`; `yue-hk` → `yue-HK`; `sr-LATN-rs` → `sr-Latn-RS`; `es-419`, `de-1996` unchanged; `en-US-u-CA-Gregory` → `en-US-u-ca-gregory`; `en-x-US` → `en-x-us`. - A canonical tag is returned unchanged (6 tags). - Case only: `iw` stays `iw`, `TL` → `tl`, `cmn-cn` → `cmn-CN`, and `zh-hans-hans` → `zh-Hans-Hans` (a tag `Intl.getCanonicalLocales` throws on). - Every reader sees the canonical spelling: a translation item, `defineTranslation`, `TranslationConfig` (`defaultLocale`, `fallbackLocale`, `supportedLocales`), `SeedSchema.locale`, `SeedLoaderConfig.locale`. - Bundle keys are folded, in authored order, values untouched: `TranslationBundleSchema`, `defineTranslationBundle`, `PlatformTranslationBundleSchema`. - Refusal: both bundle schemas refuse `zh-cn` beside `zh-CN` with exactly `[['custom', 'zh-CN']]` (code, path), and the message names both keys. - CONTROL accepts: distinct tags in one bundle (`zh-CN`, `zh-TW`, `zh-hans-cn`); `zh-cn` in one bundle and `zh-CN` in another, each parsed on its own (two bundles of `stack.translations` are separate records the runtime merges per locale). - Stored-row seam: `applyConversionsToStoredItem('translation', { locale: 'zh-cn', … })` reads back `zh-CN`; a canonical row reads back equal; an underscored `zh_cn` row reads back `zh-CN` (the order: #25822's entry makes it a tag, the case entry folds it). - Case entry at rest: `retiredAfter` is `18.2.0`; a bundle with `zh-cn` beside a different `zh-CN` is left as stored with one TODO at `translations[0].zh-cn`; an identical `ja-jp` / `ja-JP` twin is merged with one notice. - The case entry's own fixture (run by the conversion fixture suite over the whole chain): `pt-br` → `pt-BR`, bundle keys `ko-kr` → `ko-KR` and `IT` → `it`, `i18n` `en-us` → `en-US` and `zh-hans-cn` → `zh-Hans-CN`, `data[].locale` `zh-cn` → `zh-CN`; 7 notices. ## Acceptance notes - **The narrowing beside the widening.** A bundle holding two keys that fold to one tag parses on `main` today (both keys kept). After this PR it is refused, naming both keys. Measured on `6b3ba82c` over every TS/TSX/JS/MJS/CJS/JSON/YAML file in the repo: 0 such pairs, and 0 locale-keyed bundle keys in a non-canonical case outside tests and the conversion fixtures. Under AGENTS.md's level precedence (a value refused is `narrowed`, `narrowed` over `semantic`) the seat amended the claim (6573207423) to `Clause-②: no (narrowing)` / `Metadata-protocol: narrowed`, and this body and the changeset carry those lines. The maintainer ruled A on the seat's decision card (6595806433, 2026-11-03): refuse, naming both keys. The prepared merge swap (B, a one-commit patch on `263f6c89`) is not taken. - **The save door.** Measured with a held-mutation probe: `saveMetaItem` (`packages/metadata-protocol/src/protocol.ts`) runs `schema.safeParse(item)` and stores the request body verbatim for every type except `view`, plus four named grafts, so a schema transform never reaches storage. Storing the parsed locale there is a domain:server slice; the seat files it. Until it lands, a lowercase save is raw at rest and the case entry keeps folding it on every read. - `packages/lint/src/lint-liveness-properties.ts` and its test describe a bundle as `z.record(LocaleSchema, TranslationDataSchema)` in comments. The key schema is now a shape-only twin of `LocaleSchema`; the comments' point (keys are locale codes, groups live one level down) still holds. Not changed here. ## Verification ### Resumed head `62f265f0` (this PR) This PR is #29190's five commits unchanged (`5397e120` … `263f6c89`), plus one merge of `origin/main` (`62f265f0`, main at `b3d14150`). The merge conflicted in one file, `packages/spec/dropped-refinements.baseline.json`, on the count line and on two site lists main had re-sorted; it was resolved by keeping both sides: main's sites plus this PR's six `translations` sites, and the counts main's 250 schemas / 1012 sites plus this PR's 6 = 1018 (the diff of that file against main is the same +6 sites as #29190's diff against its own base). The locale fold, the collision refusal, the D3 entry and the conversion registry merged without a conflict: for the other eight files, this PR's added and removed lines against main are identical to #29190's against its base (compared per file by hash). `bash scripts/pm/os-regen-merge.sh` then read the recorded pre-merge base and reported no generated path to restore. No behaviour change. Before opening, `origin/main` was 23 commits ahead with no conflict (`git merge-tree --write-tree`); the only family-bearing file it moved was a `svelte` devDependency bump in `package.json`. Every run below is on `62f265f0`; heavy runs went through `os-verify-lock.sh` on a shared box. <details><summary>Build, spec suites, generated artifacts</summary> - `turbo run build --filter='!@objectstack/docs' --concurrency=2`: Tasks 117 successful, 117 total. - `pnpm --filter @objectstack/spec check:generated --fix`: "All 14 generated artifacts are up to date", nothing regenerated. - `pnpm --filter @objectstack/spec test`: Test Files 804 passed (804); Tests 23909 passed, 1 todo. Exit 0. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/spec test:repo`: Test Files 89 passed (89); Tests 1522 passed. Exit 0. </details> <details><summary>Dogfood battery (on 62f265f)</summary> - `pnpm --filter @objectstack/dogfood test` after the full build above, under the lock, no `OS_TEST_SHARD`, no path: `VERDICT command-exit 0` (the lock held 43m18s for spec test, typecheck, test:repo and dogfood together). Test Files 404 passed, 3 skipped (407); Tests 3326 passed, 28 skipped (3354). No failure. The `package-first-authoring` hook timeout seen on `263f6c89` (which ran beside the gate loop) did not recur; this battery ran alone. </details> <details><summary>Derived gate families and declaration checks (on 62f265f)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 104 commands (74 pnpm, 30 direct node); change set 9 paths vs merge base `b3d14150`, +541 / -20. - All 104 exit 0, each exit captured before any pipe. `dispatch-gates --ran`: "104 derived famil(ies) accounted for — 104 run, 0 NOT-MEASURED (a DERIVED zero — all 104 recorded an exit code and none of them is 3)". - `check-adr-0087-registration.mjs --base origin/main` (one of the 104): "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", `registered translation-locale-case-canonicalized`. - `check-changeset-no-major.mjs --base origin/main --event` with this title and body: exit 0, `no (narrowing)` / `narrowed`, direction arm `narrowing`, no `major`. - `check-partof-closing-keyword.mjs` with this body as `PR_BODY`: exit 0. - `check-governed-merges.mjs --branch HEAD`: 0 of 9 paths governed, 561 changed lines. </details> ### Carried from #29190 (head `263f6c89`, base `6b3ba82c`) Head `263f6c89` (base `6b3ba82c`). Round 3 is two commits on top of round 2's `3b3284c9`: `713814b9` (the case fold as its own D2 entry, the S2 text, the docs and changeset lines that name it) and `263f6c89` (the census pin's `JOIN` row, and the two collision messages naming the release). The spec suite, build, consumer packages, ablations, gate families and dogfood battery below were run on `263f6c89`. The runtime, rest, downstream-contract, lint, cli and metadata readings and the end-to-end proof were taken on `102f342e` and carry: round 3 moves no reader outside `packages/spec`, and the end-to-end path is pinned at the spec level by the stored-row seam test. Every heavy run went through `os-verify-lock.sh`; its `VERDICT` line is quoted. <details><summary>packages/spec: suite, type check, build, generated artifacts</summary> - `pnpm --filter @objectstack/spec test && pnpm --filter @objectstack/spec typecheck` (typecheck includes `check:scripts-typecheck` and `check:test-typecheck`): Test Files 789 passed, 1 skipped (790); Tests 23384 passed, 1 skipped, 1 todo. VERDICT command-exit 0. The skipped file is `scripts/root-entry-type-nameability.pin.test.ts`, which skips itself while spec's `dist` is stale (it was: the merge fallback had just been built in this worktree). Rerun after the build below: 1 file, 2 tests passed. - `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated`: "All 14 generated artifacts are up to date". VERDICT command-exit 0. Nothing regenerated: the published JSON Schema of a locale and of both bundles does not move. - Targeted, `vitest run src/system/translation.test.ts src/conversions src/migrations` (on `713814b9`): Test Files 29 passed (29), Tests 1184 passed. `src/shared/retired-key-removal-version.pin.test.ts` and `scripts/conversions-major18-merge.test.ts` (on `263f6c89`): 289 tests passed together with `translation.test.ts`. - `pnpm turbo run build --concurrency=2 --filter='./packages/**'`: Tasks 78 successful, 78 total. VERDICT command-exit 0. - `eslint --no-inline-config` over the changed `.ts` files: exit 0. </details> <details><summary>Consumer packages</summary> On `263f6c89`: - `@objectstack/core` (the i18n fallbacks; `readAuthoredTranslationLayer` replays the chain): Test Files 129 passed (129), Tests 2909 passed. VERDICT command-exit 0. - `@objectstack/metadata-protocol` (the save door, the seed loader, stored-row conversions): Test Files 297 passed, 4 skipped (301), Tests 22106 passed, 26 skipped. VERDICT command-exit 0. - `@objectstack/service-i18n`: Test Files 4 passed (4), Tests 71 passed. VERDICT command-exit 0. Carried from `102f342e`: `@objectstack/runtime` 513/513 files, 8272 passed + 19 skipped; `@objectstack/rest` 366/366, 6944 passed + 332 skipped; `@objectstack/downstream-contract` 3/3, 31; `@objectstack/lint` `src/lint-liveness-properties.test.ts` 111/111; `@objectstack/cli` `test/metadata-type-schema-gate.test.ts` (integration project) 8/8; `@objectstack/metadata` `src/serializers/typescript-serializer-annotation.test.ts` 7/7. Each VERDICT command-exit 0. </details> <details><summary>End-to-end: a zh-cn save reaches a zh-CN session (carried from 102f342)</summary> A one-off scratch test (deleted afterwards): the real `saveMetaItem` over a stub engine saves `{ name: 'zh_cn', locale: 'zh-cn', objects: { project: { label: '项目(lower)' } } }`; core's `readAuthoredTranslationLayer` reads the stored rows; `createMemoryI18n` is loaded with the stock `zh-CN` strings and then the layer. `E2E save.success= true stored rows= 1 stored.locale= zh-cn layer keys= ["zh-CN"] zh-CN serves= 项目(lower)` On `main` (`6b3ba82c`) the same stored row gave `layer keys= ["zh-cn"] zh-CN serves= 项目`. On `263f6c89` the same seam is pinned in `translation.test.ts` (`applyConversionsToStoredItem` reads `zh-cn` and `zh_cn` back as `zh-CN`), and core's suite above is green. </details> <details><summary>Ablations</summary> Round 3, on `263f6c89` in a side worktree, each through `ablation-replace` on `packages/spec/src/conversions/registry.ts` and restored to the HEAD blob (`966e69e5`). Command: `vitest run src/system/translation.test.ts src/conversions src/shared/retired-key-removal-version.pin.test.ts` (25 files, 1049 tests green at HEAD). - The case entry's row `order: 76.5` → `order: 75.5` (before #25822's entry): 1 failed, 1048 passed (the stored-row seam: `zh_cn` stays `zh-cn`, because the case entry runs before the `_` is rewritten). - The case entry's `return parsed.success ? parsed.data : undefined;` → `return parsed.success ? v : undefined;` (shape still checked, case no longer folded): 4 failed, 1045 passed (the stored-row seam, the collision test, the census row's TODO, the entry's fixture). - The census row, by hand with the file restored from a copy: dropping the release from the bundle refusal, then from the conversion's TODO, each turned the `translation-locale-tag-case-folded` row red ("names no release"), 1 failed of 60. Round 1 (on `102f342e`, still applicable to the parse side): the case fold `.overwrite(canonicalLocaleCase)` → identity gave 14 failed; the refusal's first-key lookup → `undefined` gave 2 failed (both bundle refusal tests); the key fold `folded[canonicalLocaleCase(key)]` → `folded[(key)]` gave 2 failed. </details> <details><summary>Derived gate families, declaration checks and the dogfood battery (on 263f6c8)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `263f6c89`: 105 commands (75 pnpm, 30 direct node), the same list as at `3b3284c9`; change set 9 paths vs merge base `6b3ba82cb`, 565 changed lines (+543 / -22). - All 105 exit 0, each exit captured before any pipe. `dispatch-gates --ran`: "105 derived famil(ies) accounted for — 105 run, 0 NOT-MEASURED (a DERIVED zero — all 105 recorded an exit code and none of them is 3)". - `check-adr-0087-registration.mjs --base origin/main` (one of the 105): exit 0, "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", `.changeset/28993-locale-case-canonicalized.md [BREAKING+bang+clause-②-narrowing+metadata-protocol-narrowed] registered translation-locale-case-canonicalized (new here: translation-locale-case-canonicalized)`. - `check-changeset-no-major.mjs --base origin/main --event` (this body and title, offline): exit 0, "LEVEL AXIS: this PR declares clause-② `no (narrowing)` and metadata-protocol `narrowed`", direction arm `narrowing` (BREAKING, ships `minor` during the launch window), `@objectstack/spec: minor`, no `major`. - `check-partof-closing-keyword.mjs` with this body as `PR_BODY` (offline): exit 0. - Dogfood battery, `pnpm exec turbo run build --concurrency=2 --filter='@objectstack/dogfood^...' && pnpm --filter @objectstack/dogfood test` under the lock (no `OS_TEST_SHARD`, no path), VERDICT command-exit 1 (held 36m56s): closure Tasks 68 successful, 68 total; Test Files 1 failed, 383 passed, 3 skipped (387); Tests 3161 passed, 33 skipped (3194). The one red file is `test/package-first-authoring.dogfood.test.ts`: its first `beforeAll` (`bootStack(crmStack)`) hit the 10000 ms hook timeout, and its other three describes then refused to boot because `com.example.crm` "already has a live bootStack kernel in this process" (5 tests skipped, hence 33 skipped against round 2's 28). The battery ran beside the 105-gate loop on the same 4 cores. That file alone under the lock, twice: Test Files 1 passed, Tests 5 passed, both times (VERDICT command-exit 0, held 41 s and 36 s). Reported as found; nothing skipped or narrowed. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzfTZqxsVg4dG5puUE2)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 06513bc commit 86d0a8c

9 files changed

Lines changed: 541 additions & 20 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a locale tag written in another case is corrected to its BCP-47 case — a translation saved as `zh-cn` reaches `zh-CN` sessions — and a translation bundle with two keys that fold to one tag is refused
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
A translation saved with `locale: 'zh-cn'` answered 200, and a `zh-CN` session kept rendering the stock strings. Case was accepted as written on the premise that the resolvers fold it, but a resolver answers one locale for a request, exact match first, so the stock `zh-CN` bundle won and the `zh-cn` entry was never merged into it.
11+
12+
- **The parse corrects case.** `LocaleSchema` returns each tag in its BCP-47 case: the language subtag in lower case, a script in title case, a region in upper case, and every subtag after a singleton in lower case. `zh-cn` reads back as `zh-CN`, `zh-hans-cn` as `zh-Hans-CN`, `EN` as `en`. This reaches every reader of the schema: a translation item's `locale`, a `TranslationBundle` / `PlatformTranslationBundle` locale key, `i18n.defaultLocale` / `fallbackLocale` / `supportedLocales`, a seed dataset's `locale` scope and the seed loader's `locale`. A canonical tag is returned unchanged, and nothing that parsed before is refused, with the one exception below.
13+
- **Stored rows are read back canonical.** The metadata save door stores the authored body, so a row saved as `zh-cn` stays `zh-cn` at rest. A new stored-row conversion, `translation-locale-tag-case-folded`, folds case right after `translation-locale-tag-canonicalized` rewrites `_` and padding, and the runtime-authored translation sync replays both before keying a row by its locale. A row saved as `zh-cn` is served to `zh-CN` sessions from the next sync on, with nothing to re-save. An artifact built by `@objectstack/spec` 18.2.0 or earlier is folded the same way.
14+
- **Case only.** The fold never replaces a legacy or macrolanguage subtag the way `Intl.getCanonicalLocales` does: `iw` stays `iw`, `cmn-cn` becomes `cmn-CN`. That keeps a translation on the locale its author named, and gives the same answer in every runtime.
15+
- **Upgrade path.** The ADR-0087 D3 semantic entry `translation-locale-case-canonicalized` carries both halves to `os migrate meta` and the upgrade guide. No key is added or removed, and the published JSON Schema of a locale is unchanged.
16+
- **Exact matchers that read a folded locale against a row tagged as written.** The email-template floor warning compares `i18n.supportedLocales` with `emailTemplates[].locale`, and the messaging template ladder compares the default locale with `sys_notification_template.locale`. A row tagged `zh-cn` no longer matches a supported or default locale authored as `zh-cn`, which now reads `zh-CN`: re-tag the row in its canonical case.
17+
18+
<!-- adr-0087: registered translation-locale-case-canonicalized -->
19+
20+
**BREAKING** for an author whose one translation bundle spells a locale twice, in two cases.
21+
22+
- **Before.** `{ 'zh-cn': { messages: { save: '保存' } }, 'zh-CN': { messages: { cancel: '取消' } } }` parsed as a `TranslationBundle` (or `PlatformTranslationBundle`) with both keys kept, and the `zh-cn` strings never reached a `zh-CN` session.
23+
- **Now.** Both keys fold to `zh-CN`, so keeping both would put two entries on one key and silently drop one set of strings. The parse refuses the bundle at the second key, naming both keys and the tag they fold to. Two separate bundles in `stack.translations`, one with `zh-cn` and one with `zh-CN`, are still accepted; the runtime merges them as one locale.
24+
- **Fix.** FROM a bundle holding both spellings, `{ 'zh-cn': A, 'zh-CN': B }`, TO one canonical key holding both sets of strings, `{ 'zh-CN': A and B merged }`, merged by hand: where both set the same string, keep the one you mean. No bundle in this repository has such a pair. A stored row with such a pair and different strings under the two keys is left as stored by the conversion and reported as a TODO.

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,12 @@ Every locale code (a bundle key, an item's `locale`, `i18n.defaultLocale`,
245245
between subtags (`en`, `zh-CN`, `zh-Hans-CN`). `LocaleSchema` refuses anything
246246
else at parse — `zh_CN`, a padded value, a language name — and the 18.x
247247
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.
248+
spellings in stored rows and older builds. Case is corrected, never refused: the
249+
parse returns each tag in its BCP-47 case (`zh-cn` → `zh-CN`, `zh-hans-cn` →
250+
`zh-Hans-CN`), and the conversion `translation-locale-tag-case-folded` folds a
251+
stored row's locale and an artifact built by 18.2.0 or earlier, so a
252+
translation saved as `zh-cn` is served to `zh-CN` sessions. A bundle holding two
253+
keys that fold to one tag (`zh-cn` beside `zh-CN`) is refused naming both.
249254

250255
```typescript
251256
// One locale of a TranslationBundle (e.g. zh-CN)

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"measured": {
44
"zod": "4.4.3",
55
"publishedSchemasWithDroppedRefinements": 250,
6-
"droppedRefinementSites": 1012,
6+
"droppedRefinementSites": 1018,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -129,6 +129,7 @@
129129
"manifest.reports.element.runtimeFilter.lazy",
130130
"manifest.sharingRules.element.sharedWith",
131131
"manifest.skills.element.triggerConditions.element",
132+
"manifest.translations.element",
132133
"manifest.translations.element.valueType.flows.valueType.errorMessage",
133134
"manifest.translations.element.valueType.flows.valueType.notices.valueType.message",
134135
"manifest.translations.element.valueType.flows.valueType.refusals.valueType.message",
@@ -280,6 +281,7 @@
280281
"data.options[1].manifest.reports.element.runtimeFilter.lazy",
281282
"data.options[1].manifest.sharingRules.element.sharedWith",
282283
"data.options[1].manifest.skills.element.triggerConditions.element",
284+
"data.options[1].manifest.translations.element",
283285
"data.options[1].manifest.translations.element.valueType.flows.valueType.errorMessage",
284286
"data.options[1].manifest.translations.element.valueType.flows.valueType.successMessage",
285287
"data.options[1].manifest.views.element.form",
@@ -422,6 +424,7 @@
422424
"options[1].manifest.reports.element.runtimeFilter.lazy",
423425
"options[1].manifest.sharingRules.element.sharedWith",
424426
"options[1].manifest.skills.element.triggerConditions.element",
427+
"options[1].manifest.translations.element",
425428
"options[1].manifest.translations.element.valueType.flows.valueType.errorMessage",
426429
"options[1].manifest.translations.element.valueType.flows.valueType.notices.valueType.message",
427430
"options[1].manifest.translations.element.valueType.flows.valueType.refusals.valueType.message",
@@ -504,6 +507,7 @@
504507
"data.packages.element.options[1].manifest.reports.element.runtimeFilter.lazy",
505508
"data.packages.element.options[1].manifest.sharingRules.element.sharedWith",
506509
"data.packages.element.options[1].manifest.skills.element.triggerConditions.element",
510+
"data.packages.element.options[1].manifest.translations.element",
507511
"data.packages.element.options[1].manifest.translations.element.valueType.flows.valueType.errorMessage",
508512
"data.packages.element.options[1].manifest.translations.element.valueType.flows.valueType.successMessage",
509513
"data.packages.element.options[1].manifest.views.element.form",
@@ -1463,12 +1467,13 @@
14631467
},
14641468
"system/PlatformTranslationBundle": {
14651469
"sites": [
1470+
"",
14661471
"valueType.flows.valueType.errorMessage",
1472+
"valueType.flows.valueType.notices.valueType.message",
14671473
"valueType.flows.valueType.refusals.valueType.message",
14681474
"valueType.flows.valueType.screens.valueType.description",
14691475
"valueType.flows.valueType.screens.valueType.title",
1470-
"valueType.flows.valueType.successMessage",
1471-
"valueType.flows.valueType.notices.valueType.message"
1476+
"valueType.flows.valueType.successMessage"
14721477
]
14731478
},
14741479
"system/PlatformTranslationData": {
@@ -1533,12 +1538,13 @@
15331538
},
15341539
"system/TranslationBundle": {
15351540
"sites": [
1541+
"",
15361542
"valueType.flows.valueType.errorMessage",
1543+
"valueType.flows.valueType.notices.valueType.message",
15371544
"valueType.flows.valueType.refusals.valueType.message",
15381545
"valueType.flows.valueType.screens.valueType.description",
15391546
"valueType.flows.valueType.screens.valueType.title",
1540-
"valueType.flows.valueType.successMessage",
1541-
"valueType.flows.valueType.notices.valueType.message"
1547+
"valueType.flows.valueType.successMessage"
15421548
]
15431549
},
15441550
"system/TranslationData": {

0 commit comments

Comments
 (0)