Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/rest-translate-options-default-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@objectstack/rest": patch
---

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)

`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.

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.
22 changes: 22 additions & 0 deletions .changeset/spec-i18n-default-locale-authored-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@objectstack/spec": minor
---

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)

<!-- 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. -->

**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']`.

## The ruling (#15711, recorded 2026-09-05)

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**.

- `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.
- `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.
- 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.
- `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).

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.

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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**.
|:--|:---|:---|:---|:---|
| 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` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
| 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` |
| 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` |
| 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` |
| 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` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
9 changes: 9 additions & 0 deletions content/docs/protocol/kernel/i18n-standard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ Fallback to: pt ✗ (not found)
Fallback to: en (system default) ✓
```

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

## Translation Bundles

Translations are stored in **JSON files** organized by locale and namespace.
Expand Down
25 changes: 21 additions & 4 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,23 @@ Per request, in this order:

Within the bundle, matching walks: exact (`zh-CN`) → case-insensitive →
**base language** (`zh-CN` → `zh`) → variant expansion (`zh` → `zh-CN`). If
nothing matches, the chain falls back to `en`, and finally to the literal label
on the metadata. **Translation lookup never throws** — a missing string
degrades to the next best text.
nothing matches, the request walks the stack's **declared** fallback locale
(`fallbackLocale`, else `defaultLocale`), and finally the literal label on the
metadata. **Translation lookup never throws** — a missing string degrades to
the next best text.

Two rules keep the declaration honest (#14882, #15711):

- **The authored label is the default locale's text.** A request for
`defaultLocale` consults that locale's own bundle and then answers with the
inline `label:` — it never walks the fallback chain. A stack declaring
`defaultLocale: 'zh-CN'` with `fallbackLocale: 'en'` and only an `en` bundle
serves its authored Chinese to a `zh-CN` request, and its `en` bundle to
everyone else. Shipping a `zh-CN` bundle (`os i18n extract --locales=zh-CN`)
still works and still wins; it is optional.
- **Nothing falls to `en` unless it was declared.** The chain is what the
stack declares; a caller of the `@objectstack/spec/system` resolvers that
declares no `fallbackChain` gets "requested locale, then the authored label".

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

Translating is therefore opt-in, but once you opt in it covers the **whole**
declared surface — every row of the table above, including action labels
Expand Down
115 changes: 84 additions & 31 deletions packages/rest/src/meta-i18n-declared-fallback-chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@

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

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

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

it('a provider answering undefined answers as before (en consulted)', async () => {
it('a provider answering undefined answers the same', async () => {
const undeclared = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: undefined });
expect((await readItem(makeRest(undeclared), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet');
expect((await readItem(makeRest(undeclared), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('填报单');
expect((await readItem(makeRest(undeclared), 'object', 'kpi_entry_sheet', 'fr')).label).toBe('填报单');
});

it('a provider without getDefaultLocale gets no default: nothing is the default, the chain is all there is', async () => {
// No `defaultLocale` is threaded (the seam passes nothing, never
// `'en'`), so the default-locale rule is off and the DECLARED `en`
// chain is walked for a `zh-CN` request exactly as #14882 pinned it.
const noDefault = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' });
delete noDefault.getDefaultLocale;
expect((await readItem(makeRest(noDefault), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet');
});
});

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

describe('#14882 §5 — a declared en fallback still consults en before the authored label', () => {
// `defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, no zh-CN bundle.
// Pinned as it answers today — the `en` bundle — because whether the
// authored label is the default-locale source (and so should outrank a
// declared fallback's bundle) is a CONTRACT question this card does not
// decide. #14882 changes which chain reaches the resolver, nothing else.
it('GET /meta/object/:name serves the en bundle for a zh-CN request', async () => {
const enFallback = i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' });
expect((await readItem(makeRest(enFallback), 'object', 'kpi_entry_sheet', 'zh-CN')).label).toBe('Entry Sheet');
describe('#15711 §5 — a declared en fallback never outranks the authored label for a default-locale request', () => {
// `defaultLocale: 'zh-CN'`, `fallbackLocale: 'en'`, no zh-CN bundle — the
// reflexive AI-authored config. #14882 pinned this as it answered then
// (`Entry Sheet`) because whether the authored label is the default-locale
// source was a CONTRACT question that card did not decide. #15711 ruled
// it: the authored label IS the default locale's text.
const enFallback = () =>
i18nFor({ bundles: { 'zh-CN': {}, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' });

it('GET /meta/object/:name serves the authored 填报单 for a zh-CN request', async () => {
expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', 'zh-CN'))).toEqual(AUTHORED);
});

it('GET /meta/app/:name, the list read and the types listing agree', async () => {
expect((await readItem(makeRest(enFallback()), 'app', 'kpi_app', 'zh-CN')).label).toBe('KPI 考核管理');
const [sheet] = await readList(makeRest(enFallback()), 'object', 'zh-CN');
expect(labelsOf(sheet)).toEqual(AUTHORED);
const body = await readTypes(makeRest(enFallback()), 'zh-CN');
expect(body.entries.find((e: any) => e.type === 'object').label).toBe('对象');
});

it('with NO Accept-Language the request falls to the default locale and answers authored', async () => {
expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', undefined))).toEqual(AUTHORED);
});

it('a NON-default request still walks fr → en bundle → authored', async () => {
// `fallbackLocale` keeps its full meaning for every other locale.
expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', 'fr'))).toEqual(ENGLISH);
expect((await readItem(makeRest(enFallback()), 'app', 'kpi_app', 'fr')).label).toBe('KPI Assessment');
});

it('an en request on the same stack still gets the en bundle', async () => {
expect(labelsOf(await readItem(makeRest(enFallback()), 'object', 'kpi_entry_sheet', 'en'))).toEqual(ENGLISH);
});

it('a shipped zh-CN bundle still wins; a key it omits is authored, never en', async () => {
const withZh = i18nFor({ bundles: { 'zh-CN': ZH_DATA, en: EN_DATA }, defaultLocale: 'zh-CN', fallbackLocale: 'en' });
const item = await readItem(makeRest(withZh), 'object', 'kpi_entry_sheet', 'zh-CN');
expect(item.label).toBe('填报单(bundle)');
expect(item.fields.name.label).toBe('填报单名称');
});
});
Loading
Loading