Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/i18n-locale-label-declared-enforced.md
Original file line numberDiff line numberDiff line change
@@ -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.
29 changes: 28 additions & 1 deletion packages/spec/src/api/protocol.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'),
}));
Expand Down
39 changes: 39 additions & 0 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, { description?: string }> } } };
}).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);
});
});
16 changes: 14 additions & 2 deletions packages/spec/src/system/i18n-resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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,
Expand Down
Loading