Skip to content

Commit 46803fa

Browse files
feat(spec,rest): the authored label is the default locale's text — ResolveOptions.defaultLocale skips the fallback chain, and a chain-less caller no longer falls to a literal en (#15711) (#15888)
* wip(spec,rest): defaultLocale short-circuit in localeChain, chain-less default [] , seam pass-through, pins, docs, changesets Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * wip: re-anchor system-context census lines moved by the seam docblock; build-time regenerated artifacts Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * wip(spec): declare the en chain on the two key-by-key pins that used the retired implicit default Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 06c762e commit 46803fa

10 files changed

Lines changed: 318 additions & 83 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): the metadata reads pass the declared default locale to the label resolvers, so a request for it answers with the authored label (#15711)
6+
7+
`translateOptionsFor` — the single seam every metadata-document translation in the REST server goes through — now threads `i18n.getDefaultLocale()` into `ResolveOptions.defaultLocale` beside the declared fallback chain it has passed since #14882. Both accessors are optional on `II18nService` and both are feature-detected: a provider that declares no default gets no default, one that declares no fallback gets no chain, and the seam never answers `'en'` on a provider's behalf.
8+
9+
Measured on the reporter's stack shape (`defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, an `en` bundle and no `zh-CN` bundle): `GET /api/v1/meta/object/kpi_entry_sheet` with `Accept-Language: zh-CN` — or with no header at all, which resolves to the default — now serves the authored `填报单`, not the `en` bundle's `Entry Sheet`; a `fr` request still walks the declared `en` bundle; an `en` request still gets the `en` bundle. Pinned in `meta-i18n-declared-fallback-chain.test.ts` §4 and §5.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the authored label is the default locale's text — `ResolveOptions.defaultLocale` skips the fallback chain for a default-locale request, and a chain-less caller no longer falls to a literal `en` (#15711)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) A published RUNTIME DEFAULT moves: the label resolvers' chain-less fallback, a literal `['en']` before this change, is now `[]`; and one OPTIONAL key is added to a plain TS options interface (`ResolveOptions.defaultLocale` in `packages/spec/src/system/i18n-resolver.ts`). No Zod schema, no authorable metadata key and no stored row changes shape, so the meta conversion tooling has nothing to touch and a registry entry would be false data in the one ledger this gate keeps true. Which TEXT answers a chain-less non-default request moves; nothing starts failing, and no source edit is prescribed: a host that wants `en` consulted declares `fallbackChain: ['en']`, which it always could. -->
8+
9+
**BREAKING** (launch-window convention: ships as `minor`; this entry is the signal) — the second facet of the #15711 ruling moves a published default of the `@objectstack/spec/system` label resolvers. A caller that passes no `fallbackChain` used to get a literal `['en']`; it now gets `[]`, "requested locale, then the authored label". Nothing silently falls to `en` because a literal said so: a chain is consulted only when someone declared it. In this repo the blast radius is zero production callers (the REST serving layer has declared its chain since #14882; one pin flips); out-of-repo hosts unmeasured. A host that relied on the implicit `en` declares it as `fallbackChain: ['en']`.
10+
11+
## The ruling (#15711, recorded 2026-09-05)
12+
13+
A workspace that authors its metadata labels in its default locale (`i18n.defaultLocale: 'zh-CN'`, inline `label: '填报单'`) and ships a courtesy `en` bundle used to serve `Entry Sheet` to a `zh-CN` request whenever its declared chain named `en` — a reflexive `fallbackLocale: 'en'` in an AI-authored config was enough. `os i18n check` already counted the authored text as the default locale's coverage; the runtime did not. Ruled A: **the authored label IS the default locale's text**.
14+
15+
- `ResolveOptions` gains an optional `defaultLocale?: string` — the deployment's default locale, the language its labels are authored in. When the requested locale names it (BCP-47 tags compare case-insensitively, the same rule `resolveBundleLocale` applies), the resolvers consult the requested locale's own bundle and then answer with the authored label; the fallback chain is not walked.
16+
- `fallbackChain` keeps its full meaning for every non-default request: a `fr` request still walks the `fr` bundle, then the declared `en` bundle, then the authored label.
17+
- A bundle entry for the default locale still wins when one is shipped, so `os i18n extract --locales=zh-CN` keeps working — optional now, not required.
18+
- `II18nService.getDefaultLocale()` documents that it is also what the serving layer threads into `ResolveOptions.defaultLocale`; `@objectstack/rest` passes it through its single `translateOptionsFor` seam (that package's own changeset).
19+
20+
Unchanged: `os i18n check`; both boot paths (`os serve` and the dev plugin still collapse the declaration to `fallbackLocale || defaultLocale || 'en'` before constructing the service); every request whose locale is not the default.
21+
22+
Not taken, ruled out on the card: the rule living only in `packages/rest` (every other host would re-implement it and spec could not pin it); requiring every supported locale to ship a bundle (a generated bundle that duplicates the app's own source text, the stale-translation class already closed); documenting the divergence.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5083`, `:6509`, `:6757`, `:7188`, `:7381` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5101`, `:6527`, `:6775`, `:7206`, `:7399` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |

content/docs/protocol/kernel/i18n-standard.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ Fallback to: pt ✗ (not found)
150150
Fallback to: en (system default) ✓
151151
```
152152

153+
The "system default" step is the stack's **declared** fallback
154+
(`i18n.fallbackLocale`, else `defaultLocale`), never a literal `en`. For
155+
metadata labels one more rule applies (#15711): the inline `label:` authored
156+
on the metadata **is** the default locale's text, so a request for
157+
`defaultLocale` stops at the authored label without consulting any other
158+
locale's bundle — a `zh-CN` workspace with a courtesy `en` bundle serves
159+
Chinese to `zh-CN` and English to `en`. See
160+
[Translations](/docs/ui/translations#how-a-locale-is-chosen).
161+
153162
## Translation Bundles
154163

155164
Translations are stored in **JSON files** organized by locale and namespace.

content/docs/ui/translations.mdx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,23 @@ Per request, in this order:
147147

148148
Within the bundle, matching walks: exact (`zh-CN`) → case-insensitive →
149149
**base language** (`zh-CN``zh`) → variant expansion (`zh``zh-CN`). If
150-
nothing matches, the chain falls back to `en`, and finally to the literal label
151-
on the metadata. **Translation lookup never throws** — a missing string
152-
degrades to the next best text.
150+
nothing matches, the request walks the stack's **declared** fallback locale
151+
(`fallbackLocale`, else `defaultLocale`), and finally the literal label on the
152+
metadata. **Translation lookup never throws** — a missing string degrades to
153+
the next best text.
154+
155+
Two rules keep the declaration honest (#14882, #15711):
156+
157+
- **The authored label is the default locale's text.** A request for
158+
`defaultLocale` consults that locale's own bundle and then answers with the
159+
inline `label:` — it never walks the fallback chain. A stack declaring
160+
`defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` and only an `en` bundle
161+
serves its authored Chinese to a `zh-CN` request, and its `en` bundle to
162+
everyone else. Shipping a `zh-CN` bundle (`os i18n extract --locales=zh-CN`)
163+
still works and still wins; it is optional.
164+
- **Nothing falls to `en` unless it was declared.** The chain is what the
165+
stack declares; a caller of the `@objectstack/spec/system` resolvers that
166+
declares no `fallbackChain` gets "requested locale, then the authored label".
153167

154168
Resolved labels are served straight from the REST metadata endpoints (the
155169
locale is part of the ETag), so the Console and any SDUI client get translated
@@ -240,7 +254,10 @@ reports nothing.** No `i18n` block and no bundles means one active locale, the
240254
default one, and your inline `label:` is already that locale's text — so there
241255
is no gap to report and no need to reach for `--skip-i18n`. The same holds if
242256
your source language isn't English: declare `defaultLocale: 'zh-CN'` and the
243-
tooling stops asking for English you never promised.
257+
tooling stops asking for English you never promised. The runtime reads the
258+
declaration the same way (#15711): a request for the default locale answers
259+
with the authored label, so what the gate counts as covered is what gets
260+
served.
244261

245262
Translating is therefore opt-in, but once you opt in it covers the **whole**
246263
declared surface — every row of the table above, including action labels

packages/rest/src/meta-i18n-declared-fallback-chain.test.ts

Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@
22

33
/**
44
* #14882 — the metadata reads hand the document translators the DECLARED
5-
* fallback chain, not the resolver's literal `['en']`.
5+
* fallback chain, not a literal `en`; #15711 — and the DECLARED default
6+
* locale, so a request for it answers with the authored label.
67
*
78
* The RULE lives in `@objectstack/spec/system`: the label resolvers walk
8-
* `requested locale → fallbackChain → authored label`, and honour whatever
9-
* chain they are handed (pinned in `i18n-resolver.test.ts`). What can only be
10-
* tested here is the PLUMBING — that every seam translating a metadata
11-
* document passes `fallbackChain: [i18n.getFallbackLocale()]`, the locale the
12-
* i18n service's own `t()` falls back to, which `I18nServicePlugin` receives
13-
* from the stack's `i18n` config as `fallbackLocale || defaultLocale || 'en'`.
14-
* Before this, every seam passed NO chain, so the declared `fallbackLocale`
15-
* never reached the resolver and `en` was consulted before the authored label.
9+
* `requested locale → fallbackChain → authored label` for a non-default
10+
* request, skip the chain for a request that names `defaultLocale` (the
11+
* authored label IS the default locale's text, ruled on #15711), and invent
12+
* no chain for a caller that declares none (pinned in
13+
* `i18n-resolver.test.ts`). What can only be tested here is the PLUMBING —
14+
* that every seam translating a metadata document passes
15+
* `fallbackChain: [i18n.getFallbackLocale()]`, the locale the i18n service's
16+
* own `t()` falls back to, which `I18nServicePlugin` receives from the
17+
* stack's `i18n` config as `fallbackLocale || defaultLocale || 'en'`, and
18+
* `defaultLocale: i18n.getDefaultLocale()`, the same accessor a header-less
19+
* request already resolves its locale from. Before #14882, every seam passed
20+
* NO chain, so the declared `fallbackLocale` never reached the resolver and
21+
* `en` was consulted before the authored label.
1622
*
1723
* The fixture is the card's workspace: labels authored in the default locale
1824
* (`zh-CN`), a courtesy `en` bundle for English users, and NO `zh-CN` bundle
@@ -23,7 +29,7 @@
2329
*
2430
* Seams covered: `GET /meta/:type/:name` (object and app), `GET /meta/:type`
2531
* (list), `GET /meta` (the types listing) — and the feature-detection
26-
* contract for a service that declares no fallback.
32+
* contract for a service that declares no fallback, or no default.
2733
*/
2834

2935
import { describe, it, expect, vi } from 'vitest';
@@ -286,36 +292,83 @@ describe('#14882 §3 — controls', () => {
286292
// §4 — the feature-detection contract: no declaration, no invented chain
287293
// ---------------------------------------------------------------------------
288294

289-
describe('#14882 §4 — a service that declares no fallback keeps the resolver default', () => {
290-
// The serving layer threads a DECLARATION; it does not derive one. An
291-
// i18n provider without the accessor (or answering `undefined`) gets no
292-
// chain, so the resolver's own `['en']` default applies exactly as it did
293-
// before this card — the pre-#14882 answer, pinned so a later "helpful"
294-
// derivation from `getDefaultLocale()` cannot land unnoticed (it would
295-
// decide the contract question §5 leaves open).
296-
it('a provider without getFallbackLocale answers as before (en consulted)', async () => {
295+
describe('#14882 §4 / #15711 — a service that declares no fallback gets no chain, and no en is invented', () => {
296+
// The serving layer threads DECLARATIONS; it derives nothing. An i18n
297+
// provider without `getFallbackLocale` (or answering `undefined`) gets no
298+
// chain, and since #15711 the resolver's own default is `[]`: a request
299+
// walks `requested locale → authored label`, and `en` is consulted only
300+
// when it is requested or declared. `getDefaultLocale()` IS threaded now
301+
// (#15711 ruled the question §5 used to leave open), so the `zh-CN`
302+
// request below answers authored for two independent reasons; the `fr`
303+
// request isolates the second facet, where only the `[]` default applies.
304+
it('a provider without getFallbackLocale: zh-CN (the default) and fr (not) both answer authored', async () => {
297305
const legacy = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: null });
298-
expect((await readItem(makeRest(legacy), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet');
306+
expect(labelsOf(await readItem(makeRest(legacy), 'object', 'kpi_entry_sheet', 'zh-CN'))).toEqual(AUTHORED);
307+
expect(labelsOf(await readItem(makeRest(legacy), 'object', 'kpi_entry_sheet', 'fr'))).toEqual(AUTHORED);
308+
// Control — an `en` request still finds its own bundle.
309+
expect((await readItem(makeRest(legacy), 'object', 'kpi_entry_sheet', 'en')).label).toBe('Entry Sheet');
299310
});
300311

301-
it('a provider answering undefined answers as before (en consulted)', async () => {
312+
it('a provider answering undefined answers the same', async () => {
302313
const undeclared = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: undefined });
303-
expect((await readItem(makeRest(undeclared), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet');
314+
expect((await readItem(makeRest(undeclared), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('填报单');
315+
expect((await readItem(makeRest(undeclared), 'object', 'kpi_entry_sheet', 'fr')).label).toBe('填报单');
316+
});
317+
318+
it('a provider without getDefaultLocale gets no default: nothing is the default, the chain is all there is', async () => {
319+
// No `defaultLocale` is threaded (the seam passes nothing, never
320+
// `'en'`), so the default-locale rule is off and the DECLARED `en`
321+
// chain is walked for a `zh-CN` request exactly as #14882 pinned it.
322+
const noDefault = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' });
323+
delete noDefault.getDefaultLocale;
324+
expect((await readItem(makeRest(noDefault), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet');
304325
});
305326
});
306327

307328
// ---------------------------------------------------------------------------
308-
// §5 — a stack that DECLARES en as its fallback is honoured as it reads
329+
// §5 — a stack that DECLARES en as its fallback: honoured for every request
330+
// but the default-locale one (#15711)
309331
// ---------------------------------------------------------------------------
310332

311-
describe('#14882 §5 — a declared en fallback still consults en before the authored label', () => {
312-
// `defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, no zh-CN bundle.
313-
// Pinned as it answers today — the `en` bundle — because whether the
314-
// authored label is the default-locale source (and so should outrank a
315-
// declared fallback's bundle) is a CONTRACT question this card does not
316-
// decide. #14882 changes which chain reaches the resolver, nothing else.
317-
it('GET /meta/object/:name serves the en bundle for a zh-CN request', async () => {
318-
const enFallback = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' });
319-
expect((await readItem(makeRest(enFallback), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet');
333+
describe('#15711 §5 — a declared en fallback never outranks the authored label for a default-locale request', () => {
334+
// `defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, no zh-CN bundle — the
335+
// reflexive AI-authored config. #14882 pinned this as it answered then
336+
// (`Entry Sheet`) because whether the authored label is the default-locale
337+
// source was a CONTRACT question that card did not decide. #15711 ruled
338+
// it: the authored label IS the default locale's text.
339+
const enFallback = () =>
340+
i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' });
341+
342+
it('GET /meta/object/:name serves the authored 填报单 for a zh-CN request', async () => {
343+
expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', 'zh-CN'))).toEqual(AUTHORED);
344+
});
345+
346+
it('GET /meta/app/:name, the list read and the types listing agree', async () => {
347+
expect((await readItem(makeRest(enFallback()), 'app', 'kpi_app', 'zh-CN')).label).toBe('KPI 考核管理');
348+
const [sheet] = await readList(makeRest(enFallback()), 'object', 'zh-CN');
349+
expect(labelsOf(sheet)).toEqual(AUTHORED);
350+
const body = await readTypes(makeRest(enFallback()), 'zh-CN');
351+
expect(body.entries.find((e: any) => e.type === 'object').label).toBe('对象');
352+
});
353+
354+
it('with NO Accept-Language the request falls to the default locale and answers authored', async () => {
355+
expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', undefined))).toEqual(AUTHORED);
356+
});
357+
358+
it('a NON-default request still walks fr → en bundle → authored', async () => {
359+
// `fallbackLocale` keeps its full meaning for every other locale.
360+
expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', 'fr'))).toEqual(ENGLISH);
361+
expect((await readItem(makeRest(enFallback()), 'app', 'kpi_app', 'fr')).label).toBe('KPI Assessment');
362+
});
363+
364+
it('an en request on the same stack still gets the en bundle', async () => {
365+
expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', 'en'))).toEqual(ENGLISH);
366+
});
367+
368+
it('a shipped zh-CN bundle still wins; a key it omits is authored, never en', async () => {
369+
const withZh = i18nFor({ bundles: { 'zh-CN': ZH_DATA, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' });
370+
const item = await readItem(makeRest(withZh), 'object', 'kpi_entry_sheet', 'zh-CN');
371+
expect(item.label).toBe('填报单(bundle)');
372+
expect(item.fields.name.label).toBe('填报单名称');
320373
});
321374
});

0 commit comments

Comments
 (0)