Found on 17.3.0 while wiring the newly-shipped objects.<o>._validations.<r>.message key (from #14381) into a downstream app (objectstack-ai/duly#106). The key itself works — this is about which locales reach it.
The asymmetry, in one server, from one bundle
The app ships exactly one non-source locale, zh-CN. Same running server, same session, only accept-language varying.
Authored validation message — PATCH /api/v1/data/duly_duty/<id> tripping returned_needs_note:
accept-language: zh-CN -> "请写明打回的原因——负责人需要据此知道该改什么。" ✅
accept-language: zh-CN,zh;q=0.9 -> "请写明打回的原因——负责人需要据此知道该改什么。" ✅
accept-language: zh -> "Say why the duty is being returned — …" ❌
accept-language: en -> "Say why the duty is being returned — …" ✅ correct
Dataset labels — POST /api/v1/analytics/dataset/query, the same zh:
accept-language: zh -> field labels: ['形式', '清单内职责'] ✅ translated
accept-language: en -> field labels: ['Form', 'Duties on the register']
So a client sending zh gets a screen whose dataset/view/object labels are Chinese and whose refusals are English.
Mechanism
@objectstack/spec already implements the negotiation, in resolveBundleLocale:
const base = lower.split("-")[0];
const baseMatch = available.find((code) => code.toLowerCase() === base);
if (baseMatch) return baseMatch;
const variantMatch = available.find((code) => code.toLowerCase().split("-")[0] === base);
if (variantMatch) return variantMatch; // <- 'zh' resolves to 'zh-CN' here
pickData calls it, and every document translator (translateObject, translateView, translateDataset, …) goes through pickData — which is why the labels above translate on bare zh.
The authored-validation path does not. authoredRuleMessage (@objectstack/objectql 17.3.0) asks the bridged i18n service directly:
const key = objectValidationMessageKey(messages.objectName, rule.name);
const translated = messages.translate(key, messages.locale);
if (typeof translated === "string" && translated.length > 0 && translated !== key) return translated;
return rule.message;
messages.locale is whatever preferredLocaleFromHeader returned — and that helper deliberately returns the header's first tag verbatim (zh), doing no region expansion of its own:
const top = header.split(",")[0]?.split(";")[0]?.trim();
return top && top !== "*" ? top : void 0;
With no zh bundle present the service lookup misses and the English rule.message is returned. Nothing on this path consults resolveBundleLocale.
Why it is worth closing rather than living with
Accept-Language: zh is ordinary — Playwright's Chromium sends exactly that for locale: 'zh-CN', which is how this was found; plenty of non-browser clients send a bare language tag too.
- The failure is the half-translated screen the i18n gates exist to prevent, and it is invisible to an app: the bundle key is present and correct, the coverage gate is green, and the app cannot tell that one consumer negotiates differently from the others.
- It is a one-line-shaped fix in a place the platform already owns: route the validation-message lookup through the same
resolveBundleLocale/pickData seam the document translators use, so there is one negotiation rule rather than two.
Adjacent but distinct: #14882 is about localeChain's fallbackChain defaulting to ['en'] and ignoring i18n.fallbackLocale — that is about which locale wins after a miss; this is about a path that never attempts variant matching at all.
Downstream evidence: objectstack-ai/duly#106.
Found on 17.3.0 while wiring the newly-shipped
objects.<o>._validations.<r>.messagekey (from #14381) into a downstream app (objectstack-ai/duly#106). The key itself works — this is about which locales reach it.The asymmetry, in one server, from one bundle
The app ships exactly one non-source locale,
zh-CN. Same running server, same session, onlyaccept-languagevarying.Authored validation message —
PATCH /api/v1/data/duly_duty/<id>trippingreturned_needs_note:Dataset labels —
POST /api/v1/analytics/dataset/query, the samezh:So a client sending
zhgets a screen whose dataset/view/object labels are Chinese and whose refusals are English.Mechanism
@objectstack/specalready implements the negotiation, inresolveBundleLocale:pickDatacalls it, and every document translator (translateObject,translateView,translateDataset, …) goes throughpickData— which is why the labels above translate on barezh.The authored-validation path does not.
authoredRuleMessage(@objectstack/objectql17.3.0) asks the bridged i18n service directly:messages.localeis whateverpreferredLocaleFromHeaderreturned — and that helper deliberately returns the header's first tag verbatim (zh), doing no region expansion of its own:With no
zhbundle present the service lookup misses and the Englishrule.messageis returned. Nothing on this path consultsresolveBundleLocale.Why it is worth closing rather than living with
Accept-Language: zhis ordinary — Playwright's Chromium sends exactly that forlocale: 'zh-CN', which is how this was found; plenty of non-browser clients send a bare language tag too.resolveBundleLocale/pickDataseam the document translators use, so there is one negotiation rule rather than two.Adjacent but distinct: #14882 is about
localeChain'sfallbackChaindefaulting to['en']and ignoringi18n.fallbackLocale— that is about which locale wins after a miss; this is about a path that never attempts variant matching at all.Downstream evidence: objectstack-ai/duly#106.