From ea484a4e1de6dcc17a1369a0ce81008aed9d0933 Mon Sep 17 00:00:00 2001
From: os-zhuang
Date: Tue, 11 Aug 2026 13:02:44 +0000
Subject: [PATCH] docs(spec,i18n): `GET /i18n/locales` stops declaring `label`
a display name
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`GetLocalesResponseSchema` described each locale descriptor's `label` as
"Display name of the locale" while the sole producer — `toLocaleDescriptors`
in `system/i18n-resolver.ts`, shared deliberately by the runtime dispatcher's
`/i18n` domain and service-i18n's autonomous route — sets it 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 carries the claim into the JSON Schema, the OpenAPI
surface and the SDK type.
Measured before choosing the fix: no consumer anywhere reads `label`. In this
repo every read of the body takes `code` or `isDefault`; the one wire fixture
spelling `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 that the descriptor's label is not a display name
(objectui#4039). With nothing consuming the field, the honest declaration is
the whole fix — runtime behaviour is unchanged.
The declaration now states the convention it ships, and `i18n-resolver.test.ts`
pins both sides of it: a producer that starts inventing display names and a
describe that starts promising them each go red separately. Serving real
display names (CLDR data on the server for something every client computes)
and retiring the field are both left open on #7634 — neither has a ruling.
Fixes #7634
---
.../i18n-locale-label-declared-enforced.md | 42 +++++++++++++++++++
packages/spec/src/api/protocol.zod.ts | 29 ++++++++++++-
.../spec/src/system/i18n-resolver.test.ts | 39 +++++++++++++++++
packages/spec/src/system/i18n-resolver.ts | 16 ++++++-
4 files changed, 123 insertions(+), 3 deletions(-)
create mode 100644 .changeset/i18n-locale-label-declared-enforced.md
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,