Skip to content

fix(i18n): metadata label lookup honours the declared fallbackLocale / defaultLocale instead of the literal en chain (#14882) - #15707

Merged
os-project-manager merged 7 commits into
mainfrom
claude/issue-14882-i18n-fallback-chain-honours-config
Sep 5, 2026
Merged

fix(i18n): metadata label lookup honours the declared fallbackLocale / defaultLocale instead of the literal en chain (#14882)#15707
os-project-manager merged 7 commits into
mainfrom
claude/issue-14882-i18n-fallback-chain-honours-config

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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 courtesy en translation bundle, the metadata API served the English bundle labels to a zh-CN request (Entry Sheet for an authored 填报单, KPI Assessment for KPI 考核管理). The @objectstack/spec/system label resolvers walk requested locale → fallbackChain → authored label and default the chain to a literal ['en'] when a caller passes none; every REST seam passed none, so the stack's declared i18n.fallbackLocale never reached the chain and en was consulted before the authored label.

After this PR the serving layer hands the resolvers the declared chain: a zh-CN request on that workspace resolves zh-CN → zh-CN → authored label and answers the authored Chinese labels; an en request still gets the en bundle; a zh-CN bundle, when one is shipped, still wins over the authored label.

The producer, and why it lands where it does

Measured on origin/main 7087f99c: nothing outside packages/spec passed fallbackChain. The only production caller of the localeChain-backed resolvers is packages/rest/src/rest-server.ts, at four seams — translateMetaItem (single read, via translateMetaEnvelope), translateMetaItems (list), translateMetaTypesResponse (GET /meta types listing) and the public-form schema hydration — each building { locale, packagedBase } from the II18nService it resolves per request. The sibling objectui does not call these resolvers (its resolveActionLabel is a local three-argument helper and its fallbackChain is its own formatter config), and packages/runtime / service-i18n call only resolveObjectFieldLabels, which takes a single locale's data and no chain.

The REST server could not thread the declaration because nothing on II18nService exposed it: the contract carries getDefaultLocale?() but no fallback accessor, while I18nServicePlugin already receives fallbackLocale || defaultLocale || 'en' from the stack config on both boot paths (packages/cli/src/commands/serve.ts, packages/plugins/plugin-dev/src/dev-i18n.ts) and FileI18nAdapter.t() consults exactly that locale second. So the fix is producer-side, in three pieces:

  • packages/spec/src/contracts/i18n-service.ts — new optional II18nService.getFallbackLocale?(): string | undefined: the locale the service's own t() 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.tsgetFallbackLocale() returns the constructed fallbackLocale.
  • packages/rest/src/rest-server.tsRestServer.translateOptionsFor(i18n, locale) builds { locale, fallbackChain: [fallback] } when the service declares one and { locale } otherwise, feature-detected like getPackagedObjectBase; 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.ts changes only in documentation: the lookup-order docblock and the fallbackChain description now say who supplies the chain and that the literal default applies only when a caller declares nothing.

Resolution chain, before and after

stack config request bundles before after
defaultLocale: 'zh-CN', fallbackLocale: 'zh-CN' (the card) zh-CN (or no header) en only zh-CN → en → authoredEntry Sheet zh-CN → zh-CN → authored填报单
same en en only en → en → authoredEntry Sheet en → zh-CN → authoredEntry Sheet
same zh-CN zh-CN + en zh-CN bundle ⇒ bundle text zh-CN bundle ⇒ bundle text (unchanged)
same zh-CN none authored authored (unchanged)
defaultLocale: 'zh-CN' and no fallbackLocale zh-CN en only zh-CN → en → authoredEntry Sheet boot collapses to fallbackLocale = 'zh-CN'填报单
defaultLocale: 'zh-CN', fallbackLocale: 'en' (the H3 case) zh-CN en only zh-CN → en → authoredEntry Sheet zh-CN → en → authoredEntry Sheet (unchanged)
a provider without getFallbackLocale (older host, in-memory fallback) zh-CN en only zh-CN → en → authoredEntry Sheet unchanged

The contract question this PR does not decide

Comment 5522341222 on the card records that os i18n check treats 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's open_questions.

Singled out for contract review

The resolver's literal default — localeChain still answers ['en'] when a caller passes no fallbackChain at 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/system default 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 in localeChain plus flipping the pin a caller that declares NO chain keeps the resolver's literal en default in i18n-resolver.test.ts.

Pins

New, none flipped (no existing test asserted en fallback for a caller that now declares a chain; every existing REST i18n double lacks getFallbackLocale and 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, the en request, a shipped zh-CN bundle 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, no Accept-Language (workspace default), the list read, the GET /meta types listing, both controls, the feature-detection contract (no accessor / undefined), and the H3 case.
  • packages/services/service-i18nFileI18nAdapter.getFallbackLocale() reports the constructed value, undefined when none, and agrees with what t() consults; I18nServicePlugin threads fallbackLocale through.

Ablation at the seam (fix committed first; mutation = translateOptionsFor returns { 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, the en-request, no-bundle control, feature-detection and H3 pins green. Restore by git checkout HEAD -- path, proven by git hash-object equal to the HEAD blob and an empty git 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 head ebf647748 differs from it only by the one census docs line above (outside every package), and the targeted union below was re-run there.

package typecheck vitest
@objectstack/spec tsc + check:scripts-typecheck + check:test-typecheck OK full: 472 files, 12692 passed; i18n-resolver.test.ts on ebf647748: 239 passed
@objectstack/service-i18n (producer of the declaration) OK full on ebf647748: 5 files, 74 passed
@objectstack/rest (producer of the options) OK full: 181 files, 3088 passed; the three i18n files on ebf647748: 34 passed
@objectstack/core OK full: 49 files, 1190 passed
@objectstack/runtime OK full: 226 files, 3243 passed
@objectstack/cli OK unit tier: 174 files, 2324 passed + 6 expected fail (the integration tier is CI's, per the 2026-09-01 ruling)
@objectstack/lint OK full: 96 files, 3011 passed
@objectstack/client OK full: 33 files, 437 passed

Gates: pnpm --filter @objectstack/spec check:generated — all 15 generated artifacts up to date (the optional contract member adds no api-surface or docs delta); every node scripts/pm/dispatch-gates.mjs derived 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-census reported pure line rot on five manage_metadata anchors in content/docs/permissions/system-context.mdx after 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/spec minor — the public contract surface grows (optional II18nService.getFallbackLocale).
  • @objectstack/service-i18n minorFileI18nAdapter gains a public method.
  • @objectstack/rest patch — 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

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
…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:system tests tooling labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/rest, @objectstack/service-i18n, @objectstack/spec, touching 15 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-i18n/README.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via /:object/import (route, bridged from symbol translateMetaItem — its route source's handler names it))
  • content/docs/api/wire-format.mdx (via /:object/import (route, bridged from symbol translateMetaItem — its route source's handler names it))
  • content/docs/automation/email-templates.mdx (via II18nService (symbol, a top-level interface))
  • content/docs/data-modeling/fields.mdx (via /:object/import (route, bridged from symbol translateMetaItem — its route source's handler names it))
  • content/docs/data-modeling/import-mappings.mdx (via /:object/import (route, bridged from symbol translateMetaItem — its route source's handler names it), /:object/import/jobs (route, bridged from symbol translateMetaItem — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via FileI18nAdapter (symbol, a top-level class), II18nService (symbol, a top-level interface))
  • content/docs/protocol/kernel/i18n-standard.mdx (via II18nService (symbol, a top-level interface))
  • content/docs/protocol/kernel/index.mdx (via II18nService (symbol, a top-level interface))
  • content/docs/protocol/objectql/state-machine.mdx (via /:object/import (route, bridged from symbol translateMetaItem — its route source's handler names it), /:object/import/jobs (route, bridged from symbol translateMetaItem — its route source's handler names it))
  • content/docs/protocol/objectui/actions.mdx (via /forms/:slug (route, bridged from symbol translateOptionsFor — its route source's handler names it))
  • content/docs/protocol/objectui/layout-dsl.mdx (via /forms/:slug (route, bridged from symbol translateOptionsFor — its route source's handler names it))
  • content/docs/ui/actions.mdx (via /forms/:slug (route, bridged from symbol translateOptionsFor — its route source's handler names it))
  • content/docs/ui/forms.mdx (via /forms/:slug (route, bridged from symbol translateOptionsFor — its route source's handler names it))
  • content/docs/ui/public-data-collection.mdx (via /forms/:slug (route, bridged from symbol translateOptionsFor — its route source's handler names it))
  • content/docs/ui/views.mdx (via /forms/:slug (route, bridged from symbol translateOptionsFor — its route source's handler names it))

4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v12.mdx (via RestServer (symbol, a top-level class), /:object/import (route, bridged from symbol translateMetaItem — its route source's handler names it))
  • content/docs/releases/v16.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v17.mdx (via II18nService (symbol, a top-level interface), RestServer (symbol, a top-level class), /:object/import (route, bridged from symbol translateMetaItem — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-i18n/README.md) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8e500f23ee938cf6a8f43d2578b0fe496987d62fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 82d42d37de5b1032ae053473228f4762a54d65aa — the merge of head b881599cc9381a3a1af0c2b00126bb24ceb64d22 into base 8e500f23ee938cf6a8f43d2578b0fe496987d62f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8e500f23ee938cf6a8f43d2578b0fe496987d62f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/l tests tooling

Projects

None yet

2 participants