diff --git a/.changeset/i18n-locale-label-declared-enforced.md b/.changeset/i18n-locale-label-declared-enforced.md new file mode 100644 index 0000000000..e8835eaf4f --- /dev/null +++ b/.changeset/i18n-locale-label-declared-enforced.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec,i18n): `GET /i18n/locales` stops declaring `label` a display name + +`GetLocalesResponseSchema` described each locale descriptor's `label` as +"Display name of the locale", and no producer has ever written one. The sole +producer is `toLocaleDescriptors` (`system/i18n-resolver.ts`) — deliberately +shared by the runtime dispatcher's `/i18n` domain and `service-i18n`'s +autonomous route, so there is no second implementation to diverge — and it sets +`label` to the code. `GET /api/v1/i18n/locales` answers `{ code: 'th', label: +'th' }`, never `{ code: 'th', label: 'ไทย' }`. Declared not enforced (ADR-0049), +one field wide, and the describe is what carries the claim into the generated +JSON Schema, the OpenAPI surface and the SDK type — so a client that trusts it +renders locale codes at users and only finds out by looking. objectui#4039 hit +exactly that and routed around the field: the console's language menu reads +`code` alone off this body and names locales from its own built-in table plus +`Intl.DisplayNames`. + +Patch, and describe-only. The measurement behind that: **no consumer anywhere +reads `label`**. In this repo every read of the body takes `code` or +`isDefault` (`http-dispatcher.test.ts`, `domain-handler-registry.test.ts`, +`i18n-success-envelope.conformance.test.ts`); the one wire fixture that spells +`label` sets it to the code and asserts only the array length. In objectui the +one real consumer, `apps/console/src/loadLocales.ts`, reads `entry?.code` and +documents in its header that the descriptor's label is not a display name. With +nothing consuming the field, the honest declaration is the whole fix: the +runtime behaviour is unchanged, and only the field's documented meaning moves. + +So the declaration now states the convention it ships — `label` equals `code`; +naming a locale for a UI is the client's job, where `Intl.DisplayNames` already +lives and where the choice of *which* language to name it in belongs. The two +alternatives are deliberately not taken here: serving real display names is a +capability addition with no measured pull (CLDR data on the server for +something every client can compute), and retiring the field is a heavier +response-contract action. Both stay open on #7634. + +`toLocaleDescriptors`' output and the declaration are now pinned against each +other in `i18n-resolver.test.ts`, on both sides — a producer that starts +inventing display names and a describe that starts promising them each turn it +red separately. diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index c6c439d27d..e0b5058a7e 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1713,10 +1713,37 @@ export const RejectAiPendingActionResponseSchema = lazySchema(() => z.object({ export const GetLocalesRequestSchema = lazySchema(() => z.object({})); +/** + * `GET /api/v1/i18n/locales` — the available locale set. + * + * `label` is the locale CODE, echoed back. Every descriptor on every serving + * surface comes from one helper — `toLocaleDescriptors` + * (`system/i18n-resolver.ts`), shared by the runtime dispatcher's `/i18n` + * domain and `service-i18n`'s autonomous route — and it sets `label` to the + * code, because nothing in the tree carries a locale name to set it from. + * + * This describe used to read "Display name of the locale" while no producer + * ever wrote one: the declared-not-enforced shape ADR-0049 is about, one field + * wide. A client that trusted it rendered `th` where `ไทย` belongs — which is + * what objectui#4039 hit, and why the console routes around the field + * entirely: its language menu reads `code` alone off this body and names + * locales from its own built-in table plus `Intl.DisplayNames` + * (`apps/console/src/loadLocales.ts`). So #7634 made the declaration state the + * convention it actually ships, and `i18n-resolver.test.ts` pins both halves — + * the values the producer emits, and this text promising them. + * + * Naming a locale for a UI stays the client's job: `Intl.DisplayNames` is in + * every runtime that matters, and *which* language to name it in (its own, or + * the requester's `Accept-Language`) is a caller's choice the server cannot + * make for it. Serving real names would put CLDR data behind an endpoint for + * something every client can already compute, and no consumer asks for it. + */ export const GetLocalesResponseSchema = lazySchema(() => z.object({ locales: z.array(z.object({ code: z.string().describe('BCP-47 locale code (e.g., en-US, zh-CN)'), - label: z.string().describe('Display name of the locale'), + label: z.string().describe( + 'Locale label. Equals `code` on every serving surface today — the client names locales for its UI (#7634)', + ), isDefault: z.boolean().default(false).describe('Whether this is the default locale'), })).describe('Available locales'), })); diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 16bea6c505..29ea468124 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1637,4 +1637,43 @@ describe('toLocaleDescriptors', () => { expect(toLocaleDescriptors(undefined, 'en')).toEqual([]); expect(toLocaleDescriptors([], 'en')).toEqual([]); }); + + it('sets `label` to the code, and the declaration promises exactly that (#7634)', () => { + // The SUBSTANCE pin: producer output vs the DECLARED shape, both halves. + // + // `GetLocalesResponseSchema` described `label` as "Display name of the + // locale" while this helper — the ONLY producer, shared by the + // dispatcher's `/i18n` domain and service-i18n's route — echoed the code + // back. Declared ≠ enforced, one field wide (ADR-0049), and it misleads in + // exactly one direction: a client that trusts the describe renders `th` + // where `ไทย` belongs. objectui#4039 hit that and routed around the field + // (`apps/console/src/loadLocales.ts` reads `code` alone). + // + // Two assertions because the drift has two sides. Restoring `label: code` + // to a real display name turns the first red; restoring the describe's + // display-name promise turns the second red. Neither can move alone. + const out = toLocaleDescriptors(['en', 'zh-CN', 'ja-JP', 'th'], 'en'); + expect(out).toHaveLength(4); + for (const descriptor of out) { + expect( + descriptor.label, + `toLocaleDescriptors must set label to the code (${descriptor.code}) — producing a real ` + + 'display name is a product decision nothing pulls for, and the describe promises the code (#7634)', + ).toBe(descriptor.code); + } + + const shape = (GetLocalesResponseSchema as unknown as { + shape: { locales: { element: { shape: Record } } }; + }).shape; + const description = shape.locales.element.shape.label?.description ?? ''; + expect(description.length, 'the `label` field must carry a describe at all').toBeGreaterThan(0); + expect( + description, + 'the `label` describe must not promise a display name while every producer sets the code (#7634)', + ).not.toMatch(/display name/i); + expect( + description, + 'the `label` describe must state the code-equality convention it actually ships (#7634)', + ).toMatch(/equals `code`/i); + }); }); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 767019d892..4af8c205ed 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1087,7 +1087,11 @@ function lookupObjectFieldAttr( export interface LocaleDescriptor { /** BCP-47 locale code. */ code: string; - /** Display name. Falls back to the code — nothing in the tree carries one. */ + /** + * Locale label — the CODE, echoed back, not a display name. Nothing in the + * tree carries a locale name to set it from, and naming a locale for a UI is + * the client's job (#7634); `GetLocalesResponseSchema` declares it that way. + */ label: string; /** Whether this is the stack's default locale. */ isDefault: boolean; @@ -1108,7 +1112,15 @@ export interface LocaleDescriptor { * * `label` is the code: no locale display-name source exists in the tree, and * the schema requires the field. Inventing one here (an ICU display-name - * table) would be a product decision, not an implementation detail. + * table) would be a product decision, not an implementation detail — and one + * nothing pulls for: the only real consumer of this body, the console's + * language menu, reads `code` alone and names locales itself (objectui#4039, + * `apps/console/src/loadLocales.ts`). + * + * `GetLocalesResponseSchema` used to describe the field as a display name + * anyway — declared ≠ enforced, one field wide. #7634 made the declaration say + * what this produces instead; the `label === code` convention is pinned in the + * tests below, on both sides. */ export function toLocaleDescriptors( codes: readonly string[] | undefined,