fix(core,runtime,spec): the in-memory i18n fallback learns the declared i18n.fallbackLocale - #16088
Conversation
…ed `i18n.fallbackLocale` `i18n.fallbackLocale` is authorable and `FileI18nAdapter` honours it, but the kernel's in-memory fallback was never told it: `AppPlugin.loadTranslations` injected `defaultLocale` and `supportedLocales` and nothing else, and the provider had no setter. A stack declaring `defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` answered a missing `zh-CN` key from `en` under `I18nServicePlugin` and from `zh-CN` -- not at all -- under the fallback. - `II18nService.setFallbackLocale?(locale)`: new optional member, the injection counterpart of `getFallbackLocale`, same shape as `setDefaultLocale`. - `createMemoryI18n`: holds the declared fallback and consults it per KEY after the requested locale, the way `FileI18nAdapter.t()` does. Undeclared keeps today's chain byte for byte. - `AppPlugin.loadTranslations`: threads it through the same optional-capability probe, guarded on the app having declared something. `getFallbackLocale()` stays deliberately absent from the memory fallback -- deriving one from `defaultLocale` would settle the contract question #14882 leaves open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 3 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 140 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 b18d8a2f1f515b45aac8ddbcd668fbd5dff9097d && git checkout b18d8a2f1f515b45aac8ddbcd668fbd5dff9097d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9b459b791ea9c4d83c8563816b2eb9270359917e 22112c95410a4ce7bda0ae02d334a71cbc42aadd && git checkout -B drift-repro 9b459b791ea9c4d83c8563816b2eb9270359917e && git merge --no-ff 22112c95410a4ce7bda0ae02d334a71cbc42aadd
node scripts/docs-audit/affected-docs.mjs --json 9b459b791ea9c4d83c8563816b2eb9270359917e
|
`services-checklist.mdx` is the only page in `content/docs` that enumerates what `AppPlugin.loadTranslations` injects, and the list was already missing `setSupportedLocales` (#7679) before this branch. An enumeration presented as complete is a claim, not silence, so it is corrected here rather than left one item further behind by `setFallbackLocale`. Also states the property all three share -- optional on `II18nService`, applied through the same capability probe, guarded on the app having declared the value -- since that is what makes the list safe to extend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ents, and the injection order Two factual corrections to prose landed earlier on this branch, both verified against the source rather than restated: - `FileI18nAdapter` was described as implementing "no setter for either" its default and fallback locales. It implements `setDefaultLocale` and `setSupportedLocales`; only `setFallbackLocale` is absent, because its `fallbackLocale` is `readonly` and fixed at construction. Omitting a setter is per-value, not per-provider, which is exactly why the capability probe is per-method. - The `services-checklist` enumeration of what `AppPlugin.loadTranslations` injects is presented as an ordered list, so it now matches the code order: `setDefaultLocale` -> `setFallbackLocale` -> `setSupportedLocales` -> `loadTranslations`. Also drops "and the dev plugin" from the new prose: `packages/cli`'s `serve.ts:3125` is the only non-test site that constructs `I18nServicePlugin` with `fallbackLocale || defaultLocale || 'en'`, and `os dev` reaches it by spawning `serve` as a child. The pre-existing `getFallbackLocale` docblock (#14882) keeps its own wording; it is not this card's to restate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ack-locale Brings the branch onto a current base so the derived gate family and the local gate union are measured against the tree CI will actually evaluate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…leI18nAdapter's fallback locale The previous commit narrowed "`os serve` and the dev plugin" to `os serve` alone, on an incomplete grep that searched only `packages/runtime` and `packages/cli`. `DevPlugin` does construct `I18nServicePlugin` -- `packages/plugins/plugin-dev/src/dev-plugin.ts:575`, from options built in `dev-i18n.ts:247`, which collapses `fallbackLocale || defaultLocale || 'en'` exactly as `packages/cli/src/commands/serve.ts:3125` does. Both boot paths, one collapse; the prose now says so. The `FileI18nAdapter` setter facts from the previous commit stand and are unchanged: it implements `setDefaultLocale` and `setSupportedLocales`, its `fallbackLocale` is `readonly` and constructor-only, and only `setFallbackLocale` is absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Contract review (clause ②) — PASS — PR #16088 at head
|
| # | claim | reading | verdict |
|---|---|---|---|
| 1 | i18n.fallbackLocale is authorable (TranslationConfigSchema) and honoured by FileI18nAdapter but inert on the in-memory fallback |
Card, triage and PR agree with firing controls; the declared-≠-enforced shape. The engine seat's ruling (learn the declaration, not "acceptable divergence") is within its lane and is the protocol-baseline direction: a declared key gets one answer whichever provider serves. | correct |
| 2 | Per-key second leg after the requested locale, guarded on fallbackLocale set and !== locale; the pre-existing whole-bundle ?? mergedLocale(defaultLocale) leg unchanged |
Diff read: if (value === undefined && fallbackLocale && fallbackLocale !== locale); same shape as FileI18nAdapter.t(). |
correct |
| 3 | AppPlugin.loadTranslations threads it through the same optional-capability probe as setDefaultLocale, guarded on i18nConfig?.fallbackLocale |
Diff read; multi-AppPlugin reasoning (an app declaring nothing must not clear another's fallback) holds. | correct |
| 4 | The interface member is necessary (probe compiles only because the member is declared — 2 × TS2551 without it) | Measured; the as any alternative is the shape this file does not use. |
correct |
| 5 | ⛔ getFallbackLocale() deliberately NOT added to the memory fallback (#14882's open default-locale question) |
Verified absent from the diff and pinned by a test. Correct fence. | correct |
| 6 | file-i18n-adapter.ts untouched (contrast surface) |
Not in the file list. | correct |
| 7 | Docs: services-checklist.mdx enumeration corrected (it was already one setter behind since #7679); eight other pages read and judged silent-not-false |
Accepted; content/docs/releases/** untouched. |
accepted |
② semver
@objectstack/spec / core / runtime minor — additive public widening on the first two; runtime's own .d.ts is byte-identical but it is in the fixed group, so the level is moot in effect. Correct.
③ Boundary flags
- The
II18nServicedocblock forsetFallbackLocale?states "implementing this does NOT oblige implementinggetFallbackLocale" — correct today, and it keeps i18n: metadata label lookup falls through to theenbundle on a zh-CN workspace —localeChaindefaultsfallbackChainto ['en'] and ignoresi18n.fallbackLocale, so an authored Chinese label loses to a courtesy English bundle #14882's question open rather than settling it from a degraded provider. Concur. - i18n:
@objectstack/core's exportedresolveLocaleis a verbatim second copy of@objectstack/spec'sresolveBundleLocale— one rule, two places to edit #16085 (the duplicateresolveLocaleinmemory-i18n.ts) filed from the sibling round, not folded here. Correct.
Evidence and landing
Checks on 22112c95: 35 success / 2 skipped / 0 red; check-governed-merges --test on the 7 paths: 0 hits — ordinary queue landing. Clearing, same stroke: needs:contract-review off #15694 and PR #16088 with provenance; then check-clause2-carriers --pair 16088 ⇒ ready + auto-merge SQUASH from this seat.
Generated by Claude Code
Fixes #15694
What was wrong
i18n.fallbackLocaleis authorable on the stack artifact (TranslationConfigSchema), andFileI18nAdapter— the providerI18nServicePlugininstalls — has always honoured it. Both boot paths construct it with the same collapse,fallbackLocale || defaultLocale || 'en':packages/cli/src/commands/serve.ts:3125andplugin-dev'sdev-i18n.ts:247, reached fromdev-plugin.ts:575. Itst()consults that locale, per key, after the requested one (file-i18n-adapter.ts:147).The kernel's in-memory fallback is constructed with nothing.
AppPlugin.loadTranslationsinjected the declareddefaultLocaleandsupportedLocales(#7679) into whicheveri18nservice was registered, but neverfallbackLocale, and the provider had no setter to receive one. On every stack running that fallback — any stack that declarestranslationswithout@objectstack/service-i18nregistered (not installed, ortierEnabled('i18n')false) — the declaration was inert.Reproduced against this branch's original base
c2520416c, with a live positive control on every zero reading:fallbackLocaleinpackages/core/src/fallbacks/memory-i18n.tsmergedLocale(defaultLocale)hits at line 147setFallbackLocaleinpackages/runtime/src/app-plugin.tsThe comment filter matters on the first one and was run: case-insensitively,
fallbackhits that file 9 times — it lives insrc/fallbacks/and is named for being the fallback provider. The identifierfallbackLocalewas still 0.One stack (
defaultLocale: 'zh-CN',fallbackLocale: 'en', key present only inen), same callt('objects.property.tip', 'zh-CN'):"objects.property.tip"— the key, i.e. no fallback at allFileI18nAdapter(the contrast surface, untouched)"Only in English""Only in English"The control is what makes this a divergence rather than a meaningless declaration. That the memory fallback self-declares
degradedlicenses fewer capabilities, not a different answer to the same declared key.What changed
II18nService.setFallbackLocale?(locale)(packages/spec/src/contracts/i18n-service.ts) — a new optional member, the injection counterpart of the existinggetFallbackLocale?(), same shape assetDefaultLocale?andsetSupportedLocales?.createMemoryI18nreceives it and acts on it (packages/core/src/fallbacks/memory-i18n.ts) —t()consults the declared fallback per key after the requested locale, the same second legFileI18nAdapter.t()has. Per key, not per bundle: the pre-existingresolveTranslations(locale) ?? mergedLocale(defaultLocale)line swaps whole bundles and only when the requested locale has none, so azh-CNbundle that simply lacked the key never reached anything else. That older leg is unchanged and pinned.AppPlugin.loadTranslationsthreads the declaration (packages/runtime/src/app-plugin.ts) — through the sametypeof ... === 'function'optional-capability probe assetDefaultLocale, guarded on the app having declared something (several AppPlugins can share one kernel, and an app that declares noi18nblock must not clear a fallback another app declared). Injection order is nowsetDefaultLocale,setFallbackLocale,setSupportedLocales,loadTranslations.A stack that declares no
fallbackLocalegets exactly the behaviour it has today — pinned in both suites.Deliberately NOT done
getFallbackLocale()is not added to the memory fallback. The setter is what the provider is told; the accessor is what the serving layer asks it when building the metadata-document translators' fallback chain (#14882). Answering the second fromdefaultLocale— the only value always available there — would settle the default-locale contract question #14882 leaves deliberately open, from a degraded provider. Those reads keep the resolvers' own default, which is known and intentional. The fence is pinned as a test and written into the provider's docblock.packages/services/service-i18n/src/file-i18n-adapter.tsis untouched — it is the contrast surface, not a defect.Clause-2 — this widens a published surface, on two carriers
Measured with the corrected instrument: every declaration file each changed package publishes (all
.d.ts/.d.mts/.d.ctsunderdist, whichfiles[]ships whole and theexportsmap points into), not the root barrel. Built at head, the three sources swapped back toc2520416c, rebuilt, snapshotted, restored, rebuilt, diffed.@objectstack/specdist/contracts/index.d.tsand.d.mtsgainsetFallbackLocale?(locale: string): void;onII18nService@objectstack/coredist/index.d.tsand.d.ctsgainsetFallbackLocale(locale: string): void;oncreateMemoryI18n's inferred return type@objectstack/runtimeLimb 1: fires. A member is added to a declared, published interface, and to a published factory's return type. Limb 2: does not fire. Nothing is newly accepted or rejected at runtime for any existing caller: the member is optional, the injection is guarded on the app having declared a value, and a stack that declares none is measured identical pre- and post-fix. ⇒ Clause-2 answer:
yes, additive-only.needs:contract-reviewcarried on the issue and the PR; PR stays draft.Two instrument findings worth recording:
packages/spec/dist/index.d.tsis byte-identical before and after (7f3f9bfe77c57a218e4fa93abd6ed57c9b0ab274in both snapshots) and contains 0 occurrences ofII18nService— live control on the same file: 100exportlines. The contract is published only through the./contractssubpath, which carries 5 occurrences. A re-export barrel cannot change for a subpath edit.packages/spec/dist/ui/index.d.tsdiffers but is not a surface move. 154 added and 154 removed lines; after normalising union member order the two line sets are identical, and the file contains nosetFallbackLocale. Adding an interface member permutes TypeScript's union member emission elsewhere. Excluded from the verdict as measured noise, not assumed to be.packages/spec/api-surface/**(17 files) is byte-identical before and after, andcheck:api-surfacereports the surface unchanged. Both are correct and neither contradicts the above: that ledger records top-level export names, not interface members, which is why the answer comes from the declaration diff instead.Was the interface member necessary, or would the capability probe alone have sufficed?
Asked explicitly, so measured explicitly. The member is necessary. Instrument: revert only the spec interface, keep the
app-plugin.tscall site, rebuild@objectstack/spec(preflight confirms the marker is absent from all 217 built files), thentsc --noEmiton@objectstack/runtime:Exactly 2 diagnostics, both at the probe.
i18nServiceis statically typedII18nService | undefined(app-plugin.ts:1745), so thetypeof ... === 'function'probe compiles only because the member is declared. The smaller diff exists but costs anas anycast at the call site, which neithersetDefaultLocale?norsetSupportedLocales?takes. Declaring the optional member is the shape this file already uses.Documentation
Nine
content/docspages name a symbol this diff touches. Silence is not falsehood, so only one was repaired.Repaired — one page, one falsified claim.
content/docs/kernel/services-checklist.mdxis the only page that ENUMERATES whatAppPlugin.loadTranslationsinjects, and an enumeration presented as complete is a claim. It read:That list was already one item behind before this branch (it never named
setSupportedLocales, #7679). It now names all three setters in code order, states the property they share, and states whatFileI18nAdapteractually implements.Not falsified — left alone, with the sentence checked.
content/docs/protocol/kernel/i18n-standard.mdx— "The 'system default' step is the stack's declared fallback (i18n.fallbackLocale, elsedefaultLocale), never a literalen", and "Missing keys fall back to the fallbackLocale, then return the key string". Contract-level statements, attributed to no single provider. This diff makes them true of both providers; before it they were true of one.content/docs/protocol/kernel/index.mdx— "falls back to the configured fallback locale, returning the key itself when nothing matches". Same: made true, not falsified.content/docs/ui/translations.mdx— states the negotiation chain by its INPUTS (fallbackLocale, elsedefaultLocale) and shares no identifier with the emitter, so no drift tool can list it. Read by hand for exactly that reason. Made true, not falsified.content/docs/automation/email-templates.mdx— namesII18nService.getDefaultLocale()only. Silent on the fallback locale.content/docs/references/system/translation.mdx,content/docs/references/automation/io-node-config.mdx,content/docs/deployment/environment-variables.mdx— thefallbackLocalementions are a schema table row, a per-recipient locale rule, and a pinyin-default derivation. None touched.Release-owned, read only.
content/docs/releases/v17.mdxnamesII18nService.getDefaultLocale()at line 3603 (per-recipient notification locale) and has no claim aboutfallbackLocale,createMemoryI18nor the in-memory i18n fallback anywhere — grep for those returns only an unrelated query-engine "in-memory fallback" at line 1922. Not falsified, nothing owed, not edited.Verification
Head
22112c954, working tree clean. All heavy work throughscripts/pm/os-verify-lock.sh; every exit code captured by redirecting before any pipe; every verdict quoted from the tool's own output.traponEXIT INT TERM, absolute paths, restore viagit checkout HEAD -- pathnever the bare form. The three implementation files swapped back toc2520416c; mutation proven on disk by anchoredgrep -cin both directions plusgit hash-objectdiffering from the HEAD blob on all three; rebuilt, and the mutation proven to have reached the artifact byscripts/ablation-dist-preflight.mjs PACKAGE setFallbackLocale --absenton all three packages; measured; restored; restore proven by a whole-treegit status --porcelainreturning 0 lines and blob equality with HEAD; rebuilt; preflight re-run without--absent; re-measured.@objectstack/coreTests 10 failed | 51 passed (61);@objectstack/runtimeTests 2 failed | 4 passed (6)@objectstack/coreTests 61 passed (61);@objectstack/runtimeTests 6 passed (6)packages/runtime/vitest.config.tsaliases@objectstack/coreand@objectstack/specto source — but a forgotten ablation rebuild fails GREEN and silently, so it was done and proven anyway.@objectstack/core50 files / 1215 passed;@objectstack/runtime233 files / 3311 passed;@objectstack/spec481 files / 12886 passed.@objectstack/spec+@objectstack/core+@objectstack/runtime, exit 0, including each package'scheck:test-typecheckagainst itstsconfig.test.json. Both new/edited test files confirmed inside their tsc program viatsc --listFiles(1 hit each; control on a non-existent path: 0), so that green verdict really does cover them.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack— provenance line namesobjectstack-ai/objectstack at commit 22112c954,--repo ... holds, 7 paths vs merge baseb3820c3e2, three-dot. The derived family is byte-identical to the derivation taken before the last commit. 65 gate commands run, 64 exit 0.pnpm check:dual-build-cjs-loads, exit 3 with its own verdictPREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ ... This is NOT a pass: nothing was measured.It wants a full-repopnpm build(32 packages have nodist/in this worktree). Recorded as NOT MEASURED — not a pass, not a failure; CI'sBuild Corejob owns it. Its siblingcheck:type-check-debt --re-measurecarries the same full-workspace-build prerequisite and is likewise left to CI.pnpm lint=eslint . --no-inline-config, exit 0.pnpm check:nul-bytesexit 0, plus a directgrep -naPcontrol-byte scan over the changed files: no hits.The base was refreshed by merging
origin/mainatb3820c3e2(never a rebase, never a force-push); the Corepack CI fixf50c394dawas already an ancestor.Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ