Skip to content

i18n: GET /i18n/locales declares a locale label as "Display name" but every producer sets it to the code #7634

Description

@yinlianghui

Found while implementing objectstack-ai/objectui#4039 (the console language menu now reads this endpoint). Filing only; not fixed there — out of that issue's scope, and the consumer side deliberately routes around it.

What

GetLocalesResponseSchema (packages/spec/src/api/protocol.zod.ts) declares each locale descriptor as:

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'),
  isDefault: z.boolean().default(false).describe('Whether this is the default locale'),
}))

The only producer of those descriptors is toLocaleDescriptors (packages/spec/src/system/i18n-resolver.ts):

return codes.map((code) => ({ code, label: code, isDefault: code === defaultLocale }));

label is the code echoed back. Both serving surfaces go through that one helper — the runtime dispatcher's /i18n domain and service-i18n's autonomous route — deliberately, and correctly, so there is no second implementation that behaves differently. The field is therefore never a display name on any surface: GET /api/v1/i18n/locales answers { code: 'th', label: 'th' }, never { code: 'th', label: 'ไทย' }.

Why it is worth writing down

label reads as a display name in the schema, in the generated SDK type, and in the OpenAPI surface. A client that trusts the description renders locale codes to users and only finds out by looking. objectui#4039 hit exactly this and had to make the opposite decision explicitly: the console's language menu names locales from its own built-in metadata plus Intl.DisplayNames and ignores the endpoint's label, because using it would have put th in the menu where ไทย belongs.

This is the declared-but-not-enforced shape ADR-0049 is about, one field wide: the vocabulary exists, no implementation stands behind it.

Observation-class, not a live defect

Nothing a user hits today, as far as I can tell — there is no current consumer of the field. The one client that reached for it (the console) now does not. Recording it so triage can grade it rather than leaving it in a PR comment.

Options, not a decision

  • Retire the field — drop label from the descriptor and the schema; code is the whole payload, and naming a locale is the client's job anyway (that is where Intl.DisplayNames and any built-in native names live). Smallest surface, and consistent with what the one real consumer chose.
  • Implement it — have toLocaleDescriptors produce a real display name. Needs a decision on which language it is named in (its own, or the requester's Accept-Language), and puts CLDR data on the server for something every client can already compute.

No pull for the second that I can see; recording both so the call is made rather than inherited.


Generated by Claude Code

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Triage (route-A pass): domain:spec appended — routing only, finding grade untouched, no ownership taken.

    • Landing anchors verified on origin/main @ 5823d59: the declaration at packages/spec/src/api/protocol.zod.ts:1719 ('Display name of the locale') and the sole producer at packages/spec/src/system/i18n-resolver.ts:1118 (label: code) — both exactly as filed. Declared≠enforced, one field wide.
    • Why domain:spec: both anchor files are in packages/spec, and either exit (retire the field, or implement a real display name) changes the response contract — acceptance-surface red line. If the grading round promotes toward retirement, the spec-property-retirement playbook (ADR-0049 enforce-or-remove, ADR-0087 conversion) applies.
    • Dedup: distinct from i18n: TranslationBundle has no surface for screen-flow UI — wizard screens render source-language only, even in a fully localized app #7646 (missing flows translation surface — a different i18n contract question); no other open card touches the locale descriptor.
    • Note for grading: the card's own options section makes this decision-adjacent — if promoted, it likely promotes into the decision box rather than the queue, since retire-vs-implement is a contract call (the filer sees "no pull" for implement; the one real consumer routed around the field).

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Promoted finding → pm:queue by the spec-lane seat, executing the maintainer's lane-triage authorization (spec-lane PM session chat, 2026-08-11, verbatim: 「你的车道你可以执行 分类改标」; session session_01JY2Q5Xto1u8YHADgrZDTnk). Grading rationale: declared ≠ enforced on GET /i18n/locales' label — the describe promises a display name, every producer ships the code. The dispatch resolves it in the declared=enforced direction the dev measures as cheapest honest fix (real display names at the producers, or the describe stops promising); if the producer fix lands outside packages/spec, transfer per the cross-seat protocol applies.


    Generated by Claude Code

  3. self-assigned this
    on Aug 11, 2026
  4. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 2 (spec lane)
    Session: session_01JY2Q5Xto1u8YHADgrZDTnk
    Branch: claude/issue-7634-locale-label-declared-enforced
    Worktree: cloud dev session (own container)
    Domain: domain:spec
    File surface: the GET /i18n/locales response schema's label describe (spec side) + every producer that sets label (measure — likely rest/runtime i18n route). Resolution direction is declared = enforced: real display names at the producers, or the describe stops promising one — dev measures which is cheapest honest and reports; producer fixes outside packages/spec are in-scope for this card ONLY if mechanical (wiring a name table); anything larger reports back for a cross-seat follow-up card. Stop on breach; explain in the report.
    Container & model: M (declared≠enforced with a producer measurement), mode:cloud, model: opus
    Serial constraints cleared: no in-flight writer on the i18n locale surface (open PRs + queue checked 12:3xZ).


    Generated by Claude Code

  5. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    OS-DEV-REPORT — #7634

    {
      "issue": 7634,
      "status": "done",
      "branch": "claude/issue-7634-locale-label-declared-enforced",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7757",
      "premise_still_valid": true,
      "summary": "Premise verified on BOTH halves before any edit, so the card's narrowest branch applied. In this repo every read of the /i18n/locales body takes `code` or `isDefault` (http-dispatcher.test.ts x4, domain-handler-registry.test.ts:130, i18n-success-envelope.conformance.test.ts:162, i18n-resolver.test.ts:1624); the one fixture that spells `label` (client/src/i18n-wire-dialect.test.ts:46) SETS it to the code and asserts only array length. Positive control: the same grep method finds real `isDefault`/`code` reads repo-wide, so the absence of `label` reads is measured, not missed. The objectui half was verified directly rather than left unverified — objectui is anonymously clonable here, so I read it at 3b7d1cc: apps/console/src/loadLocales.ts reads entry?.code and its header states the descriptor's label is not a display name, and app-shell LocaleSwitcher.tsx names locales from a built-in native-name table plus Intl.DisplayNames, its header giving the reason ('a server label would put th in the menu where ไทย belongs'). No consumer reads the field as a display name. Fix is therefore describe-only: protocol.zod.ts:1719's describe now states the shipped convention ('Locale label. Equals `code` on every serving surface today — the client names locales for its UI (#7634)') with a schema-level comment recording why display naming stays a client concern; LocaleDescriptor.label's JSDoc and the toLocaleDescriptors header stop promising a display name; one pin asserts the SUBSTANCE (producer output = declared shape) on both sides. No new capability surface, no field retirement, no runtime behaviour change.",
      "tests": "REVERSE VERIFICATION, direction predicted before running, both halves confirmed to move INDEPENDENTLY (that independence is the pin's point): (A) producer half broken (label: `NAME(${code})`) -> RED on the value assertion, \"expected 'NAME(en)' to be 'en'\", describe assertions stayed green; (B) declaration half broken (describe restored to 'Display name of the locale') -> RED on the describe assertion, \"Expected: /display name/i, Received: 'Display name of the locale'\", value assertion stayed green. Restored via `git checkout HEAD -- <path>` against the checkpoint commit; tree verified clean at HEAD afterwards; no git stash at any point. GATES (all local, scoped to this card's surface): `pnpm --filter @objectstack/spec test` -> 'Test Files 377 passed (377) / Tests 9884 passed (9884)'; `pnpm --filter @objectstack/spec typecheck` (tsc + check:scripts-typecheck + check:test-typecheck) -> pass, '265 error(s) held in test-typecheck-debt.json' unchanged; `check:generated` -> 'All 13 generated artifacts are up to date' (includes check:authorable-surface, check:api-surface, check:docs, check:export-origins); `pnpm check:i18n` -> 'check-i18n-bundles: OK (9 package(s) — all bundles in sync)' with EXIT=0 read explicitly rather than through a pipe, per that gate's own warning; `node scripts/check-nul-bytes.mjs` -> OK, 7115 files, no raw control bytes; runtime i18n conformance + domain-handler-registry -> 55 passed; service-i18n full suite -> 62 passed; client i18n-wire-dialect -> 4 passed. TWO PREREQUISITES, not drift: gen:api-surface and check:i18n each failed first-run in the fresh worktree demanding a build (spec dist had no .d.ts; the CLI was unbuilt) — both green after `pnpm --filter @objectstack/spec build` and `pnpm exec turbo run build --filter=@objectstack/cli`. GENERATED-ARTIFACT CLOSURE: gen:schema + gen:openapi + gen:docs re-run; the new describe reaches packages/spec/json-schema/api/GetLocalesResponse.json and the bundled objectstack.json, both gitignored build outputs; content/docs/references/api/protocol.mdx renders only the array-level describe for this schema, so there is NO committed generated-file delta — which is why check:generated is green with a clean tree, not because generation was skipped. CI: reported at draft-PR time per the #6644 L2 contract; gate conclusions not yet read, no idle-polling.",
      "open_questions": [
        {
          "question": "The two heavier exits the filer listed stay unresolved — this PR only stops the declaration lying. Does either get built?",
          "options": [
            "A. Leave as-is: label stays the code, declared honestly; clients name locales (Intl.DisplayNames is in every runtime that matters).",
            "B. Implement real display names in toLocaleDescriptors — needs a ruling on which language to name a locale in (its own, or the requester's Accept-Language) and puts CLDR data on the server.",
            "C. Retire the field from the descriptor and the response schema — smallest surface, and what the one real consumer's behaviour implies."
          ],
          "recommendation": "A — and no action is needed from the maintainer to take it. Business need: measured zero. Nothing in this repo or objectui reads `label`, and the one client that reached for it (objectui#4039) deliberately routed around it and now computes names itself, so B is a capability surface with no pull — the startup-focus principle answers that directly. Long-term soundness: A leaves the contract honest with no server-side CLDR data to version or localize, and keeps the which-language question where it belongs (the caller). Hard-to-get-wrong: A's pin makes the drift structurally unrepeatable — a producer inventing names and a describe promising them each go red on their own, so declared = enforced is now enforced by a test rather than by reviewer memory. C was explicitly off the table on this card (removing a shipped response field is a heavier contract action nobody has ruled); it stays available and is strictly cheaper to do later than B is to undo."
        }
      ],
      "out_of_scope_findings": []
    }

    Notes for the PM, outside the JSON:

    • No skip-changeset label — this PR carries .changeset/i18n-locale-label-declared-enforced.md (patch on @objectstack/spec). The describe ships in the JSON Schema, OpenAPI and SDK type surfaces, so it is user-visible; patch because no runtime behaviour, no type shape and no field moves.
    • Platform subscription posture — this cloud session was auto-subscribed to its own draft PR with injected instructions to stay resident and drive it to green. Per the maintainer ruling of 2026-08-11 ([finding] Cloud dev sessions get auto-subscribed to their own PR with a drive-to-green posture that contradicts os-dev L2 (report-at-draft; PM owns CI convergence) #7512) that posture is overridden by the dispatch contract: report at draft-PR time, no idle-polling, no self check-ins; CI convergence, the ready-flip and landing are the PM's. Nothing beyond the standard subscription text was involved.

    Generated by Claude Code

  6. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Step-7 review — ACCEPT (spec-lane PM, session session_01JY2Q5Xto1u8YHADgrZDTnk). PR: #7757 (draft, Fixes #7634).

    • The premise-hinged ruling executed exactly: the no-consumer premise was measured in BOTH repos — this repo via quoted-exact-name greps with a positive control, objectui verified directly against a read-only clone @ 3b7d1cc (not taken on the filer's word), where the one real consumer reads code and says in its own header that it names locales itself. Premise held ⇒ narrowest fix: the describe stops promising a display name, states the convention honestly, and a two-sided pin binds producer output to the declared shape.
    • Reverse verification ran BOTH directions with the halves moving independently — producer broken ⇒ value assertion red / describe green; declaration restored to the old promise ⇒ describe red / value green. That independence is precisely what makes the pin two-sided rather than decorative.
    • Both heavier exits left explicitly open on the card (serve real display names — no pull; retire the field — heavier contract action), matching the dispatch's ⛔.
    • Changeset patch, correctly graded (describe ships in JSON Schema/OpenAPI/SDK surfaces; no runtime or shape change). No committed generated file moves ⇒ no os-regen relay needed — flips independently on gate conclusions.

    Landing: flip when this PR's checks conclude green (started 13:05Z).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions