fix(i18n): metadata label lookup honours the declared fallbackLocale / defaultLocale instead of the literal en chain (#14882) - #15707
Conversation
The REST metadata translation seams pass the document translators the fallback chain the deployment declared — read from the new optional II18nService.getFallbackLocale(), implemented by FileI18nAdapter — instead of leaving the resolvers on their literal en default. A zh-CN workspace with a courtesy en bundle now resolves a zh-CN request to its authored Chinese labels. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…y the rest-server edit Mechanical: node scripts/check-system-context-census.mjs --fix rewrote five line anchors on the manage_metadata row after translateOptionsFor was added above them; the census re-check is green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
📓 Docs Drift CheckThis PR changes 3 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 82d42d37de5b1032ae053473228f4762a54d65aa && git checkout 82d42d37de5b1032ae053473228f4762a54d65aa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8e500f23ee938cf6a8f43d2578b0fe496987d62f b881599cc9381a3a1af0c2b00126bb24ceb64d22 && git checkout -B drift-repro 8e500f23ee938cf6a8f43d2578b0fe496987d62f && git merge --no-ff b881599cc9381a3a1af0c2b00126bb24ceb64d22
node scripts/docs-audit/affected-docs.mjs --json 8e500f23ee938cf6a8f43d2578b0fe496987d62f
|
…8n-fallback-chain-honours-config
…erged tree Regenerated with pnpm gen:system-context-census after merging origin/main 1c14214 (os-regen path, merged without a text merge). Anchor line numbers only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…8n-fallback-chain-honours-config
…erged tree Regenerated with pnpm gen:system-context-census after merging origin/main 8e500f2 (os-regen path, merged without a text merge). Anchor line numbers only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
Fixes #14882
Summary
On a workspace whose metadata labels are authored in the default locale (
defaultLocale: 'zh-CN',fallbackLocale: 'zh-CN') and which ships only a courtesyentranslation bundle, the metadata API served the English bundle labels to azh-CNrequest (Entry Sheetfor an authored填报单,KPI AssessmentforKPI 考核管理). The@objectstack/spec/systemlabel resolvers walkrequested locale → fallbackChain → authored labeland default the chain to a literal['en']when a caller passes none; every REST seam passed none, so the stack's declaredi18n.fallbackLocalenever reached the chain andenwas consulted before the authored label.After this PR the serving layer hands the resolvers the declared chain: a
zh-CNrequest on that workspace resolveszh-CN → zh-CN → authored labeland answers the authored Chinese labels; anenrequest still gets theenbundle; azh-CNbundle, when one is shipped, still wins over the authored label.The producer, and why it lands where it does
Measured on
origin/main7087f99c: nothing outsidepackages/specpassedfallbackChain. The only production caller of thelocaleChain-backed resolvers ispackages/rest/src/rest-server.ts, at four seams —translateMetaItem(single read, viatranslateMetaEnvelope),translateMetaItems(list),translateMetaTypesResponse(GET /metatypes listing) and the public-form schema hydration — each building{ locale, packagedBase }from theII18nServiceit resolves per request. The sibling objectui does not call these resolvers (itsresolveActionLabelis a local three-argument helper and itsfallbackChainis its own formatter config), andpackages/runtime/service-i18ncall onlyresolveObjectFieldLabels, which takes a single locale's data and no chain.The REST server could not thread the declaration because nothing on
II18nServiceexposed it: the contract carriesgetDefaultLocale?()but no fallback accessor, whileI18nServicePluginalready receivesfallbackLocale || defaultLocale || 'en'from the stack config on both boot paths (packages/cli/src/commands/serve.ts,packages/plugins/plugin-dev/src/dev-i18n.ts) andFileI18nAdapter.t()consults exactly that locale second. So the fix is producer-side, in three pieces:packages/spec/src/contracts/i18n-service.ts— new optionalII18nService.getFallbackLocale?(): string | undefined: the locale the service's ownt()consults after the requested one;undefined/ absent means nothing was declared and a serving layer must then leave the resolver's default in place rather than invent a chain.packages/services/service-i18n/src/file-i18n-adapter.ts—getFallbackLocale()returns the constructedfallbackLocale.packages/rest/src/rest-server.ts—RestServer.translateOptionsFor(i18n, locale)builds{ locale, fallbackChain: [fallback] }when the service declares one and{ locale }otherwise, feature-detected likegetPackagedObjectBase; all four seams use it.Not changed:
localeChain's own['en']default for a caller that declares nothing (see "Singled out" below); the core in-memory i18n fallback (createMemoryI18n) — it has no declared fallback, deliberately does not implement the accessor, and keeps today's behaviour on every path; no consumer-side workaround anywhere.packages/spec/src/system/i18n-resolver.tschanges only in documentation: the lookup-order docblock and thefallbackChaindescription now say who supplies the chain and that the literal default applies only when a caller declares nothing.Resolution chain, before and after
defaultLocale: 'zh-CN',fallbackLocale: 'zh-CN'(the card)zh-CN(or no header)enonlyzh-CN → en → authored⇒Entry Sheetzh-CN → zh-CN → authored⇒填报单enenonlyen → en → authored⇒Entry Sheeten → zh-CN → authored⇒Entry Sheetzh-CNzh-CN+enzh-CNbundle ⇒ bundle textzh-CNbundle ⇒ bundle text (unchanged)zh-CNdefaultLocale: 'zh-CN'and nofallbackLocalezh-CNenonlyzh-CN → en → authored⇒Entry SheetfallbackLocale = 'zh-CN'⇒填报单defaultLocale: 'zh-CN',fallbackLocale: 'en'(the H3 case)zh-CNenonlyzh-CN → en → authored⇒Entry Sheetzh-CN → en → authored⇒Entry Sheet(unchanged)getFallbackLocale(older host, in-memory fallback)zh-CNenonlyzh-CN → en → authored⇒Entry SheetThe contract question this PR does not decide
Comment 5522341222 on the card records that
os i18n checktreats the authored labels as full coverage of the default locale, while the runtime lookup walks other bundles before the authored label — a gate-vs-runtime disagreement on what "default locale" means, and with it "must every supported locale ship a bundle". Delivering the config-honouring half did not require answering it (the reproduction resolves once the declared chain is threaded), so it is not answered here: the H3 row above is pinned as it behaves today at both the resolver and the REST seam, with comments naming the open question. It becomes its own card, filed by the PM from the report'sopen_questions.Singled out for contract review
The resolver's literal default —
localeChainstill answers['en']when a caller passes nofallbackChainat all. Kept because the card's fix lives in the producer, its only production caller now declares a chain, and the blast radius of changing a published@objectstack/spec/systemdefault is the reviewer's call, not this dispatch's. Evidence for that review: after this PR the only path that reaches the default is a service without a declared fallback (the in-memory fallback, an older provider), where it preserves today's behaviour exactly. Rolling the default to[]later is a one-line change inlocaleChainplus flipping the pina caller that declares NO chain keeps the resolver's literal en defaultini18n-resolver.test.ts.Pins
New, none flipped (no existing test asserted
enfallback for a caller that now declares a chain; every existing REST i18n double lacksgetFallbackLocaleand keeps its answer):packages/spec/src/system/i18n-resolver.test.ts—#14882 — a declared fallback chain, at the resolver: the card shape (authored labels), the no-bundle control, theenrequest, a shippedzh-CNbundle winning, the declared-en(H3) shape unchanged, and the no-chain default.packages/rest/src/meta-i18n-declared-fallback-chain.test.ts— the same shapes through the real route handlers:GET /meta/object/:name,GET /meta/app/:name, noAccept-Language(workspace default), the list read, theGET /metatypes listing, both controls, the feature-detection contract (no accessor /undefined), and the H3 case.packages/services/service-i18n—FileI18nAdapter.getFallbackLocale()reports the constructed value,undefinedwhen none, and agrees with whatt()consults;I18nServicePluginthreadsfallbackLocalethrough.Ablation at the seam (fix committed first; mutation =
translateOptionsForreturns{ locale }only, confirmed on disk by marker count 1 / removed-text count 0; the subject is a same-package relative import, so no dist rebuild leg applies): 6 failed / 7 passed — exactly the card-shape cases red, theen-request, no-bundle control, feature-detection and H3 pins green. Restore bygit checkout HEAD -- path, proven bygit hash-objectequal to the HEAD blob and an emptygit diff HEAD.Verification (union re-run on ebf6477)
Consumer readings (downstream of
@objectstack/spec, closure built first — 71 workspace packages):Full suites were measured on the source tree of
0fe0153fb; the final headebf647748differs from it only by the one census docs line above (outside every package), and the targeted union below was re-run there.@objectstack/spectsc+check:scripts-typecheck+check:test-typecheckOKi18n-resolver.test.tsonebf647748: 239 passed@objectstack/service-i18n(producer of the declaration)ebf647748: 5 files, 74 passed@objectstack/rest(producer of the options)ebf647748: 34 passed@objectstack/core@objectstack/runtime@objectstack/cliunittier: 174 files, 2324 passed + 6 expected fail (theintegrationtier is CI's, per the 2026-09-01 ruling)@objectstack/lint@objectstack/clientGates:
pnpm --filter @objectstack/spec check:generated— all 15 generated artifacts up to date (the optional contract member adds no api-surface or docs delta); everynode scripts/pm/dispatch-gates.mjsderived gate runnable outside CI green (spec: api-surface, authorable-surface, browser-reachable-entries, docs, dual-source-exports, empty-state, entry-nameability, error-code-provenance, export-origins, exported-any, liveness, llms-txt, meta-url-spelling, react-blocks, skill-refs, spec-changes, strictness-ledger, variant-docs, yaml-examples; repo: nul-bytes, changeset-gate-self-tests, cross-package-test-inputs, doc-authoring, dual-build-cjs-loads, logger-receiver-detach, authz-resolver, dispatcher-error-vocabulary, engine-double-contract, where-matcher, published-readme-exports, type-check-coverage, type-check-debt and the rest of the derived list; lint: doc-formula-expressions). One mechanical follower:check-system-context-censusreported pure line rot on fivemanage_metadataanchors incontent/docs/permissions/system-context.mdxafter the helper was inserted above them — repaired with its own--fix, re-check green. Not runnable locally by design:check:react-declaration-parity(needs objectui's manifest) and the CI-context invocations (RUNNER_TEMP, matrix shards,PR_BODY/PR_NUMBER).Changesets
@objectstack/specminor — the public contract surface grows (optionalII18nService.getFallbackLocale).@objectstack/service-i18nminor —FileI18nAdaptergains a public method.@objectstack/restpatch — a fix; no option surface added.Out of scope, filed as an unassigned observation: #15694 (the in-memory i18n fallback never receives the declared
i18n.fallbackLocale). Queued sibling #14972 (injected system-column labels) is a different defect and is not touched here.🤖 Generated with Claude Code
https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
Generated by Claude Code