You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 39a78b1
Browse filesBrowse the repository at this point in the historyBrowse 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>
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)
**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.
0 commit comments