diff --git a/.changeset/15178-translation-bundle-split-settings-platform-only.md b/.changeset/15178-translation-bundle-split-settings-platform-only.md new file mode 100644 index 00000000000..261d92a1429 --- /dev/null +++ b/.changeset/15178-translation-bundle-split-settings-platform-only.md @@ -0,0 +1,78 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-settings': minor +--- + +**BREAKING for per-app translation bundles** — the translation bundle type splits in two: `settings` is a PLATFORM group and a per-app bundle may no longer declare it (#15178) + +Clause-②: yes + +`TranslationDataSchema` served two different bundles at once — the per-app one an +application authors (`stack.translations`, `defineTranslationBundle`) and the +code-authored bundles the platform packages ship. It now names the **per-app** +bundle entry and declares ten groups; the new `PlatformTranslationDataSchema` / +`PlatformTranslationBundleSchema` (types `PlatformTranslationData` / +`PlatformTranslationBundle`) carry the eleven-group platform face, `settings` +included. + +### Migration — FROM → TO + +| You wrote | Write instead | +| --- | --- | +| `defineTranslationBundle({ 'zh-CN': { settings: { mail: { title: '邮件投递' } } } })` | delete the `settings` group — there is no per-app replacement key | +| `defineStack({ translations: [{ 'zh-CN': { settings: … } }] })` | delete the `settings` group from the bundle entry | +| `const b: TranslationBundle = { en: { settings: … } }` — a PLATFORM package's own bundle | `const b: PlatformTranslationBundle = { en: { settings: … } }` | +| `const d: TranslationData = { settings: … }` — a PLATFORM package's own locale entry | `const d: PlatformTranslationData = { settings: … }` | + +**The one-line fix for an application: delete the `settings` group.** Settings copy +is not application-authorable at all — `settings` is keyed by +`SettingsManifest.namespace` and only platform code declares a manifest, so the +only namespaces a per-app entry could ever address were the platform's own. +`settingsCommon` is **not** affected — the Settings UI shell strings (the source +badges, under `settingsCommon.sourceLabels`) stay on the per-app face; only the +per-namespace manifest copy under `settings` leaves. +Run `os migrate meta --from 17` to list the mechanical edits for existing +sources; apply them by hand. + +### What the deletion changes, which is not nothing + +⚠️ This is **not** a lossless delete, and the record says so rather than claiming +the house phrase. Both bundles load into ONE served tree — `AppPlugin`'s +`loadTranslations` and every platform plugin's `kernel:ready` contribution both +call `II18nService.loadTranslations`, which deep-merges — and the +`resolveSettings*` family and the console's settings labels read that merged +tree. So an app-authored `settings` branch did resolve. + +**It was a gap filler, not an override.** The app's bundles are loaded in +`AppPlugin`'s own `start()` (kernel Phase 2); the platform's settings +translations arrive from `SettingsServicePlugin`'s `kernel:ready` hook (Phase +3); `deepMerge` gives the **later** source the leaf. So the platform won every +key both bundles defined, and a per-app entry rendered **only where the platform +bundle carried no string for that key and locale** — the platform ships `en`, +`zh-CN`, `ja-JP` and `es-ES`. + +**What to expect after upgrading.** Where the platform already carried the +string, nothing changes on screen — that value was the one being served all +along. Where your entry was filling a gap, that Settings screen now renders the +**manifest's own literal, which is English** (the `?? fallback` every +`resolveSettings*` helper ends in). Those are the screens to re-read. If a +platform string is wrong or missing for your locale, correct it in the platform +bundle (`@objectstack/service-settings`'s `settingsBuiltinTranslations`) — do +not re-add the app-side copy, which the platform overwrites on every boot +wherever it has its own value. + +No deprecation window: the per-app door refuses the key by name from this major, +and the rejection carries the prescription above. + +### Unchanged + +The registered `translation` metadata type (`TranslationItemSchema`) still +declares `settings` — this ruling covers the file-authored bundle. `GET +/api/v1/i18n/translations/:locale` still declares it on its response, because the +served document is the merged tree; `GetTranslationsResponseSchema` is typed +against the platform face for exactly that reason. + +Ruling batch #132 item 2 letter ② (2026-09-13) — 「同意」. The card's original +"removal" disposition is struck: `settings` is a live platform key. + + diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index dd0ccf21dc7..0a41d474b91 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -398,10 +398,18 @@ Three rules the shape enforces, all of them closed since #4001: - **The top level is the declared group set and nothing else** — `objects`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`, `pages`, - `flows`, `settings`, `metadataForms`, `settingsCommon`. A group invented - beside them (`"account"`, `"fields"`, `"list"`, `"industries"`) is rejected - **by name** at both authoring doors, with the group to use instead named in - the rejection. + `flows`, `metadataForms`, `settingsCommon`. A group invented beside them + (`"account"`, `"fields"`, `"list"`, `"industries"`) is rejected **by name** + at both authoring doors, with the group to use instead named in the + rejection. +- **`settings` is a PLATFORM group and an application bundle may not carry + it.** It is keyed by `SettingsManifest.namespace`, and only platform code + declares a manifest — so the only namespaces an application could ever + address are the platform's own. Writing it in `stack.translations` (or in + `defineTranslationBundle`) is refused by name with that prescription; the + platform's own bundles author it against `PlatformTranslationData`, and the + served document (`GET /i18n/translations/:locale`) carries it because it is + the merge of every loaded bundle. - **Field options are a map keyed by the option's stored `value`** — never an array of `{ value, label }` pairs, and never the display label. See [Orphan Keys and Option Keys](#orphan-keys-and-option-keys). @@ -1046,10 +1054,12 @@ options: { 'Direct Mail': '直邮' } // ❌ keyed by the label — never resolv options: { 'direct-mail': '直邮' } // ❌ a variant spelling of the value ``` -`messages`, `settings`, `settingsCommon` and -`metadataForms` are **not** checked: their keys are owned by application code, -plugins, and the platform's own metadata-type registry rather than by this -stack's metadata, so there is no set of legal names to resolve against. +`messages`, `settingsCommon` and `metadataForms` are **not** checked: their +keys are owned by application code, plugins, and the platform's own +metadata-type registry rather than by this stack's metadata, so there is no set +of legal names to resolve against. (`settings` is not checked either, and since +it left the per-app bundle it cannot appear in one at all — the parse refuses it +before any lint runs.) ### Translation Service Integration diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 04a73e91547..c89a11f3f05 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1615,9 +1615,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | -| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | +| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index a2e0ce9dd5c..83f44075f94 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1535 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1537 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -31,9 +31,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 34 | 273 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 158 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **195** | **1535** | 14 protocol modules | +| **Total** | **195** | **1537** | 14 protocol modules | --- @@ -316,7 +316,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **34 pages, 273 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **34 pages, 275 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -354,7 +354,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`supplier-security.zod.ts`](/docs/references/system/supplier-security) | `SupplierAssessmentStatus`, `SupplierRiskLevel`, `SupplierSecurityAssessment`, `SupplierSecurityPolicy`, `SupplierSecurityRequirement` | | [`tenant.zod.ts`](/docs/references/system/tenant) | `DatabaseLevelIsolationStrategy`, `DatabaseProvider`, `QuotaEnforcementResult`, `RowLevelIsolationStrategy`, `SchemaLevelIsolationStrategy`, `Tenant`, `TenantConnectionConfig`, `TenantIsolationConfig`, `TenantIsolationLevel`, `TenantQuota`, `TenantSecurityPolicy`, `TenantUsage` | | [`tracing.zod.ts`](/docs/references/system/tracing) | `OpenTelemetryCompatibility`, `OtelExporterType`, `SamplingDecision`, `SamplingStrategyType`, `Span`, `SpanAttributeValue`, `SpanAttributes`, `SpanEvent`, `SpanKind`, `SpanLink`, `SpanStatus`, `TraceContext`, `TraceContextPropagation`, `TraceFlags`, `TracePropagationFormat`, `TraceSamplingConfig`, `TraceState`, `TracingConfig` | -| [`translation.zod.ts`](/docs/references/system/translation) | `ActionResultDialogTranslation`, `CoverageBreakdownEntry`, `FieldTranslation`, `Locale`, `ObjectTranslationData`, `TranslationBundle`, `TranslationConfig`, `TranslationCoverageResult`, `TranslationData`, `TranslationDiffItem`, `TranslationDiffStatus`, `TranslationItem` | +| [`translation.zod.ts`](/docs/references/system/translation) | `ActionResultDialogTranslation`, `CoverageBreakdownEntry`, `FieldTranslation`, `Locale`, `ObjectTranslationData`, `PlatformTranslationBundle`, `PlatformTranslationData`, `TranslationBundle`, `TranslationConfig`, `TranslationCoverageResult`, `TranslationData`, `TranslationDiffItem`, `TranslationDiffStatus`, `TranslationItem` | | [`worker.zod.ts`](/docs/references/system/worker) | `BatchProgress`, `QueueConfig`, `Task`, `TaskExecutionResult`, `TaskPriority`, `TaskRetryPolicy`, `TaskStatus`, `WorkerStats` | --- diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index d0a74a6331c..7f1755bef65 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -12,8 +12,8 @@ description: Translation protocol schemas ## TypeScript Usage ```typescript -import { ActionResultDialogTranslationSchema, CoverageBreakdownEntrySchema, FieldTranslationSchema, LocaleSchema, ObjectTranslationDataSchema, TranslationBundleSchema, TranslationConfigSchema, TranslationCoverageResultSchema, TranslationDataSchema, TranslationDiffItemSchema, TranslationDiffStatusSchema, TranslationItemSchema } from '@objectstack/spec/system'; -import type { ActionResultDialogTranslation, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationItem } from '@objectstack/spec/system'; +import { ActionResultDialogTranslationSchema, CoverageBreakdownEntrySchema, FieldTranslationSchema, LocaleSchema, ObjectTranslationDataSchema, PlatformTranslationBundleSchema, PlatformTranslationDataSchema, TranslationBundleSchema, TranslationConfigSchema, TranslationCoverageResultSchema, TranslationDataSchema, TranslationDiffItemSchema, TranslationDiffStatusSchema, TranslationItemSchema } from '@objectstack/spec/system'; +import type { ActionResultDialogTranslation, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, PlatformTranslationBundle, PlatformTranslationData, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationItem } from '@objectstack/spec/system'; // Validate data const result = ActionResultDialogTranslationSchema.parse(data); @@ -147,11 +147,139 @@ Translation data for a single field | **message** | `string` | optional | Translated rejection message — overlays the rule's authored `message` on every rejected write | +--- + +## PlatformTranslationBundle + +Map of locale codes to platform translation data + +**Type:** `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>` + + +--- + +## PlatformTranslationData + +Platform translation data — the per-app groups plus the platform-only `settings` + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objects** | `Record; … }>` | optional | Object translations keyed by object name | +| **apps** | `Record }>` | optional | App translations keyed by app name | +| **messages** | `Record` | optional | UI message translations keyed by message ID | +| **globalActions** | `Record` | optional | Global action translations keyed by action name | +| **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | +| **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | +| **pages** | `Record` | optional | Page translations keyed by page name | +| **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | +| **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | +| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | +| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | + +### Nested Shape: `PlatformTranslationData.objects[string]` + +Translation data for a single object + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated singular label | +| **pluralLabel** | `string` | optional | Translated plural label | +| **description** | `string` | optional | Translated object description | +| **fields** | `Record }>` | optional | Field-level translations | +| **_views** | `Record }>` | optional | View translations keyed by view name | +| **_actions** | `Record` | optional | Action translations keyed by action name | +| **_sections** | `Record` | optional | Section translations keyed by section name | +| **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | +| **_validations** | `Record` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) | + +### Nested Shape: `PlatformTranslationData.apps[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | ✅ | Translated app label | +| **description** | `string` | optional | Translated app description | +| **navigation** | `Record` | optional | Navigation group translations keyed by group ID | + +### Nested Shape: `PlatformTranslationData.globalActions[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated action label | +| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | +| **confirmText** | `string` | optional | Translated confirmation prompt | +| **successMessage** | `string` | optional | Translated success toast/message | +| **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | +| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | + +### Nested Shape: `PlatformTranslationData.dashboards[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated dashboard title | +| **description** | `string` | optional | Translated dashboard description | +| **actions** | `Record` | optional | Header action label translations keyed by action url/key | +| **widgets** | `Record` | optional | Widget translations keyed by widget id | +| **globalFilters** | `Record }>` | optional | Global-filter translations keyed by the filter `name` (a filter that authors no `name` is keyed by its `field`) | + +### Nested Shape: `PlatformTranslationData.datasets[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated dataset label | +| **description** | `string` | optional | Translated dataset description | +| **dimensions** | `Record` | optional | Dimension translations keyed by dimension name (`DatasetDimensionSchema.name`) | +| **measures** | `Record` | optional | Measure translations keyed by measure name (`DatasetMeasureSchema.name`) | + +### Nested Shape: `PlatformTranslationData.pages[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated page label (nav / breadcrumb) | +| **description** | `string` | optional | Translated page description | +| **title** | `string` | optional | Translated `page:header` title (defaults to `label`) | +| **subtitle** | `string` | optional | Translated `page:header` subtitle | +| **components** | `Record` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) | + +### Nested Shape: `PlatformTranslationData.flows[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated flow label | +| **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | + +### Nested Shape: `PlatformTranslationData.metadataForms[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated metadata-type display label (overrides registry label) | +| **description** | `string` | optional | Translated metadata-type description | +| **sections** | `Record` | optional | Section translations keyed by section.name | +| **fields** | `Record` | optional | Field translations keyed by field path (dot-notation for nested fields) | + +### Nested Shape: `PlatformTranslationData.settingsCommon` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **sourceLabels** | `{ env?: string; global?: string; tenant?: string; user?: string; … }` | optional | Source badge labels by resolution layer | + +### Nested Shape: `PlatformTranslationData.settings[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **title** | `string` | optional | Translated settings manifest title | +| **description** | `string` | optional | Translated settings manifest description | +| **groups** | `Record` | optional | Group translations keyed by group key | +| **keys** | `Record }>` | optional | Per-setting field translations keyed by setting key | +| **actions** | `Record` | optional | Action button translations keyed by action id | + + --- ## TranslationBundle -Map of locale codes to translation data +Map of locale codes to per-app translation data **Type:** `Record; apps?: Record; messages?: Record; globalActions?: Record; … }>` @@ -222,7 +350,7 @@ Coverage breakdown for a single translation group ## TranslationData -Translation data for objects, apps, and UI messages +Per-app translation data for objects, apps, and UI messages ### Properties @@ -236,7 +364,6 @@ Translation data for objects, apps, and UI messages | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | -| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | @@ -311,16 +438,6 @@ Translation data for a single object | **label** | `string` | optional | Translated flow label | | **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | -### Nested Shape: `TranslationData.settings[string]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **title** | `string` | optional | Translated settings manifest title | -| **description** | `string` | optional | Translated settings manifest description | -| **groups** | `Record` | optional | Group translations keyed by group key | -| **keys** | `Record }>` | optional | Per-setting field translations keyed by setting key | -| **actions** | `Record` | optional | Action button translations keyed by action id | - ### Nested Shape: `TranslationData.metadataForms[string]` | Property | Type | Required | Description | @@ -387,9 +504,9 @@ One locale of translations — the `translation` metadata type | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | -| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | +| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | | **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") | | **name** | `string` | optional | Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent | | **label** | `string` | optional | Human-readable label shown in metadata lists | @@ -472,16 +589,6 @@ Translation data for a single object | **label** | `string` | optional | Translated flow label | | **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | -### Nested Shape: `TranslationItem.settings[string]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **title** | `string` | optional | Translated settings manifest title | -| **description** | `string` | optional | Translated settings manifest description | -| **groups** | `Record` | optional | Group translations keyed by group key | -| **keys** | `Record }>` | optional | Per-setting field translations keyed by setting key | -| **actions** | `Record` | optional | Action button translations keyed by action id | - ### Nested Shape: `TranslationItem.metadataForms[string]` | Property | Type | Required | Description | @@ -497,6 +604,16 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **sourceLabels** | `{ env?: string; global?: string; tenant?: string; user?: string; … }` | optional | Source badge labels by resolution layer | +### Nested Shape: `TranslationItem.settings[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **title** | `string` | optional | Translated settings manifest title | +| **description** | `string` | optional | Translated settings manifest description | +| **groups** | `Record` | optional | Group translations keyed by group key | +| **keys** | `Record }>` | optional | Per-setting field translations keyed by setting key | +| **actions** | `Record` | optional | Action button translations keyed by action id | + --- diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 4e16119f3b3..d43d6bf1df0 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -80,7 +80,8 @@ export default defineStack({ | Page labels and `page:header` copy | `pages..label` / `description` / `title` / `subtitle` — on a `kind: 'slotted'` page the header under `slots.header` is the page's header | | Page component copy, by component id | `pages..components..title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` | | Screen-flow wizards (flow label, screen headings, screen field copy) | `flows..label` / `flows..screens..title` / `.fields..label` / `.placeholder` — see the boundary note below | -| Global actions, settings, messages | `globalActions`, `settings`, `messages` | +| Global actions, messages | `globalActions`, `messages` | +| Settings UI shell copy (the source badge on a settings row) | `settingsCommon.sourceLabels.` — the per-namespace settings copy under `settings` is **platform-only**: an app bundle carrying it is refused by name, and the platform's own strings are translated in `@objectstack/service-settings`'s bundle | | A label written as an inline locale map (`label: { en: 'Members', 'zh-CN': '成员' }`) | Nowhere — it is written on the metadata and resolved at render time; see **Current boundaries** below | The metadata types resolved per request are **object, view, action, app, @@ -188,7 +189,8 @@ register. Common layouts: Files are not the only door. A `translation` metadata item — created in the Studio, through the metadata API, or by an agent — carries one locale's worth -of the **same** groups a file bundle uses, plus the `locale` it translates: +of the **same** groups a file bundle uses, bar the one exception noted below, +plus the `locale` it translates: {/* os:check */} ```ts @@ -215,7 +217,11 @@ Two things to know: a silent skip is the hardest kind of missing translation to diagnose. - Only the groups on this page are accepted, and since #4001 that is literally true: a key none of them declares is rejected, in a runtime item **and** in a - file-authored bundle. Keys from the retired `o.` shape (`o`, `app`, + file-authored bundle. One group differs between the two doors: `settings` is + **platform-only** — a file bundle refuses it by name, and although the + registered `translation` item still declares it, the only namespaces it can + address are the platform's own, because only platform code declares a settings + manifest. Keys from the retired `o.` shape (`o`, `app`, `nav`, `dashboard`, `_globalOptions`, `_meta`, …) carry a message naming the group to use instead — they used to save cleanly and then render nothing (#3778). Everything else gets the nearest declared key suggested. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 195fe7375f9..1e815436dec 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -264,4 +264,4 @@ directory rather than per file. | `marketplace/` | 29 | | `qa/` | 6 | | `shared/` | 20 | -| `system/` | 351 | +| `system/` | 352 | diff --git a/docs/qa/platform-checklist/areas/i18n.json b/docs/qa/platform-checklist/areas/i18n.json index 4400ea7f3cd..edcb636e1fd 100644 --- a/docs/qa/platform-checklist/areas/i18n.json +++ b/docs/qa/platform-checklist/areas/i18n.json @@ -251,7 +251,7 @@ "evidence": "the /i18n/locales response" }, { - "clause": "GET /i18n/translations/:locale returns a non-empty bundle whose key paths all sit within the declared translation-group vocabulary (translationDataShape) — a key outside the declared groups, or a raw dotted key handed to a consumer, is the silent-strip class", + "clause": "GET /i18n/translations/:locale returns a non-empty bundle whose key paths all sit within the declared translation-group vocabulary (PlatformTranslationDataSchema — the SERVED document is the merged tree, so it is the eleven-group platform face and not the ten-group per-app one) — a key outside the declared groups, or a raw dotted key handed to a consumer, is the silent-strip class", "oracle": "api", "verify": "translations for zh-CN is non-empty; spot-check that sampled keys resolve under objects/_views/_actions/_sections/apps.navigation/dashboards/pages/settings groups, not a fourth dialect", "evidence": "the /translations/zh-CN response + the key check" @@ -299,7 +299,7 @@ ], "traps": ["stale-console-bundle", "hydration-race", "wrong-panel", "dispatcher-vs-hono-route"], "source": [ - "packages/spec/src/system/translation.zod.ts#translationDataShape (translationDataShape — the authoritative group vocabulary: objects/_views/_actions/_sections, apps.navigation, messages, globalActions, dashboards, pages, settings, metadataForms, settingsCommon)", + "packages/spec/src/system/translation.zod.ts#appTranslationDataShape (appTranslationDataShape — the group vocabulary an APPLICATION may author: objects/_views/_actions/_sections, apps.navigation, messages, globalActions, dashboards, datasets, pages, flows, metadataForms, settingsCommon. `settings` is platform-only since #15178 and lives in platformSettingsShape, spread into PlatformTranslationDataSchema and TranslationItemSchema)", "examples/app-showcase/src/system/translations/index.ts (full-column coverage rationale)", "packages/services/service-i18n/src/i18n-service-plugin.ts#i18n (GET /i18n/locales | /translations/:locale | /labels/:object/:locale; { success, data } envelope #3636/#3675; resolveObjectFieldLabels nested shape #3778/#3833; the plugin mount and the dispatcher /i18n domain serve the same routes interchangeably)", "packages/services/service-i18n/src/file-i18n-adapter.ts#getLocales (getLocales / getTranslations — unloaded locale → {}; fallbackLocale applies per-KEY in t(), not to the bulk route)", diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index f4b8d4a1fe8..a9c02d486ce 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { TranslationData } from '@objectstack/spec/system'; +import type { PlatformTranslationData } from '@objectstack/spec/system'; /** * English (en) — built-in settings manifest translations. @@ -9,7 +9,7 @@ import type { TranslationData } from '@objectstack/spec/system'; * Keeping them explicit here lets the resolver chain (locale → fallback → literal) * always have at least an English entry to fall back to. */ -export const en: TranslationData = { +export const en: PlatformTranslationData = { settingsCommon: { sourceLabels: { env: 'Env', diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 91a04695851..13bab6deb0f 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -1,11 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { TranslationData } from '@objectstack/spec/system'; +import type { PlatformTranslationData } from '@objectstack/spec/system'; /** * Español (es-ES) — built-in settings manifest translations. */ -export const esES: TranslationData = { +export const esES: PlatformTranslationData = { settingsCommon: { sourceLabels: { env: 'Entorno', diff --git a/packages/services/service-settings/src/translations/index.ts b/packages/services/service-settings/src/translations/index.ts index 8f90f32cb5b..433513984b9 100644 --- a/packages/services/service-settings/src/translations/index.ts +++ b/packages/services/service-settings/src/translations/index.ts @@ -4,14 +4,15 @@ * Built-in Settings translations. * * Mirrors the CRM example's `src/translations/{en,zh-CN,ja-JP}.ts` convention — - * one file per locale, aggregated into a `TranslationBundle` here. + * one file per locale, aggregated into a `PlatformTranslationBundle` here — + * the platform face, because `settings` is a platform-only group. * * Hosts merge `settingsBuiltinTranslations` into the i18next resource tree * under whatever namespace makes sense (the console wires it as `system`), * making keys resolvable as `.settings..{title,description,...}`. */ -import type { TranslationBundle } from '@objectstack/spec/system'; +import type { PlatformTranslationBundle } from '@objectstack/spec/system'; import { en } from './en.js'; import { zhCN } from './zh-CN.js'; import { jaJP } from './ja-JP.js'; @@ -19,7 +20,7 @@ import { esES } from './es-ES.js'; export { en, zhCN, jaJP, esES }; -export const settingsBuiltinTranslations: TranslationBundle = { +export const settingsBuiltinTranslations: PlatformTranslationBundle = { en, 'zh-CN': zhCN, 'ja-JP': jaJP, diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index 7411c5bc962..2fc44d9b1d8 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -1,11 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { TranslationData } from '@objectstack/spec/system'; +import type { PlatformTranslationData } from '@objectstack/spec/system'; /** * 日本語 (ja-JP) — built-in settings manifest translations. */ -export const jaJP: TranslationData = { +export const jaJP: PlatformTranslationData = { settingsCommon: { sourceLabels: { env: '環境変数', diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index b505112dcef..b79eb708471 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -1,11 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { TranslationData } from '@objectstack/spec/system'; +import type { PlatformTranslationData } from '@objectstack/spec/system'; /** * 简体中文 (zh-CN) — built-in settings manifest translations. */ -export const zhCN: TranslationData = { +export const zhCN: PlatformTranslationData = { settingsCommon: { sourceLabels: { env: '环境变量', diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index c2ff6f40b45..50472f743ce 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -500,6 +500,10 @@ "Plan (type)", "PlanParsed (type)", "PlanSchema (const)", + "PlatformTranslationBundle (type)", + "PlatformTranslationBundleSchema (const)", + "PlatformTranslationData (type)", + "PlatformTranslationDataSchema (const)", "PresignedUrlConfig (type)", "PresignedUrlConfigSchema (const)", "QueueConfig (const)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 145f12e8707..b3958cde2ec 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -879,6 +879,17 @@ "system/Plan:limits", "system/Plan:priceMonthly", "system/Plan:priceYearly", + "system/PlatformTranslationData:apps", + "system/PlatformTranslationData:dashboards", + "system/PlatformTranslationData:datasets", + "system/PlatformTranslationData:flows", + "system/PlatformTranslationData:globalActions", + "system/PlatformTranslationData:messages", + "system/PlatformTranslationData:metadataForms", + "system/PlatformTranslationData:objects", + "system/PlatformTranslationData:pages", + "system/PlatformTranslationData:settings", + "system/PlatformTranslationData:settingsCommon", "system/PresignedUrlConfig:contentType", "system/PresignedUrlConfig:expiresIn", "system/PresignedUrlConfig:maxSize", @@ -1276,7 +1287,6 @@ "system/TranslationData:metadataForms", "system/TranslationData:objects", "system/TranslationData:pages", - "system/TranslationData:settings", "system/TranslationData:settingsCommon", "system/TranslationDiffItem:aiConfidence", "system/TranslationDiffItem:aiSuggested", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index 581a7aa4750..ffd9cdbb2ec 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -326,6 +326,10 @@ "PackagePublishResultSchema": "system/PackagePublishResult", "Plan": "system/Plan", "PlanSchema": "system/Plan", + "PlatformTranslationBundle": "system/PlatformTranslationBundle", + "PlatformTranslationBundleSchema": "system/PlatformTranslationBundle", + "PlatformTranslationData": "system/PlatformTranslationData", + "PlatformTranslationDataSchema": "system/PlatformTranslationData", "PresignedUrlConfig": "system/PresignedUrlConfig", "PresignedUrlConfigSchema": "system/PresignedUrlConfig", "QueueConfig": "system/QueueConfig", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 4db7a018acf..9c06a6fdd0c 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -479,6 +479,10 @@ "Plan": "src/system/license.zod.ts#Plan (type)", "PlanParsed": "src/system/license.zod.ts#PlanParsed (type)", "PlanSchema": "src/system/license.zod.ts#PlanSchema (const)", + "PlatformTranslationBundle": "src/system/translation.zod.ts#PlatformTranslationBundle (type)", + "PlatformTranslationBundleSchema": "src/system/translation.zod.ts#PlatformTranslationBundleSchema (const)", + "PlatformTranslationData": "src/system/translation.zod.ts#PlatformTranslationData (type)", + "PlatformTranslationDataSchema": "src/system/translation.zod.ts#PlatformTranslationDataSchema (const)", "PresignedUrlConfig": "src/system/object-storage.zod.ts#PresignedUrlConfig (type)", "PresignedUrlConfigSchema": "src/system/object-storage.zod.ts#PresignedUrlConfigSchema (const)", "QueueConfig": "src/system/worker.zod.ts#QueueConfig (type)", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index 1a951e75a20..ad5a1f45f25 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -178,6 +178,8 @@ "system/PNCounter", "system/PackagePublishResult", "system/Plan", + "system/PlatformTranslationBundle", + "system/PlatformTranslationData", "system/PresignedUrlConfig", "system/QueueConfig", "system/QuotaEnforcementResult", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 5db152247ae..0e78b162a02 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -27,7 +27,7 @@ import { import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod'; import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod'; import { ActionDescriptorSchema } from '../automation/node-executor.zod'; -import { TranslationDataSchema } from '../system/translation.zod'; +import { PlatformTranslationDataSchema } from '../system/translation.zod'; // #5950 / #5882 — the ADR-0010 read-side protection envelope both metadata-item // responses publish. Same three vocabularies the resolver filters against, so a // value this spec cannot name is a value the resolver would have dropped. @@ -3167,9 +3167,16 @@ export const GetTranslationsRequestSchema = lazySchema(() => z.object({ locale: z.string().describe('BCP-47 locale code'), })); +/** + * The served document is the MERGE of every loaded bundle — each platform + * package's own contribution at `kernel:ready` plus the app's + * `stack.translations` — so it carries the platform-only `settings` group and + * is typed against the platform face, not the per-app one + * (`system/translation.zod.ts`). + */ export const GetTranslationsResponseSchema = lazySchema(() => z.object({ locale: z.string().describe('Locale code'), - translations: TranslationDataSchema.describe('Translation data'), + translations: PlatformTranslationDataSchema.describe('Translation data'), })); export const GetFieldLabelsRequestSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 2371b6f4407..562b23f7b16 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7113,6 +7113,105 @@ const elementFormRemoved: MetadataConversion = { }, }; +/** + * `translation..settings` on a PER-APP bundle — the platform-only + * group leaving `stack.translations` with the type split (protocol 18, + * #15178, ruling batch #132 item 2 letter ②). + * + * ⛔ NOT a lossless delete, and this entry says so rather than claiming the + * house phrase. `settings` is keyed by `SettingsManifest.namespace`, and only + * platform code declares a manifest — so the only namespaces an application + * could address were the PLATFORM's own. Both bundles are loaded into ONE + * served tree (`AppPlugin.loadTranslations` and each platform plugin's + * `kernel:ready` contribution both call `II18nService.loadTranslations`, which + * deep-merges), and `resolveSettingsTitle` / the console's `useSettingsLabel` + * read that merged tree, so an app-authored entry DID resolve. + * + * What it did NOT do is override the platform. `AppPlugin` loads the app's + * bundles in its own `start()` (kernel Phase 2); `SettingsServicePlugin` + * contributes the platform's settings translations from a `kernel:ready` hook + * (Phase 3); `deepMerge` gives the LATER source the leaf. So the platform won + * every key both bundles defined, and a per-app entry rendered only where the + * platform bundle carried no string for that key and locale — a gap filler on + * a namespace the application does not own. Dropping it therefore takes those + * gaps back to the manifest's own literal (the `?? fallback` every + * `resolveSettings*` helper ends in), which is a VISIBLE change and not a + * no-op. The semantic entry + * `18.translation-per-app-settings-platform-only.ts` is where an author is + * told that, because a notice reading "(removed)" does not say it. + * + * ⚠️ The BUNDLE shape only. `TranslationItemSchema` still declares `settings` + * (the registered `translation` metadata type is out of this ruling's scope), + * so a bare item entry replaying through this seam is left exactly as it is — + * the opposite of the `translation-component-submit-label-removed` neighbour, + * which retires its key at both doors and therefore walks both shapes. Getting + * this backwards would strip a key its own schema still accepts. + * + * The bundle is told from an item structurally rather than by key spelling: + * `locale` is REQUIRED on an item and never present on a bundle entry (the + * bundle's keys ARE the locales), and the candidate value must be a dict whose + * every key is a declared translation group — which an `objects` record, the + * one other dict-of-dicts at that depth, is not. + */ +const translationPerAppSettingsRemoved: MetadataConversion = { + id: 'translation-per-app-settings-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'stack.translations[]..settings', + summary: + "per-app translation group 'settings' removed (#15178 — it is keyed by SettingsManifest.namespace " + + 'and only platform code declares a manifest, so an app-authored entry could only fill gaps the ' + + "platform's own bundle left in the one merged served tree, and was overwritten wherever both " + + 'defined the key; those gaps now fall back to the manifest literal, and the group stays on the ' + + 'PLATFORM bundle, PlatformTranslationData)', + apply(stack, emit) { + /** The top-level groups a translation bundle entry may carry (either face). */ + const GROUPS = new Set([ + 'objects', 'apps', 'messages', 'globalActions', 'dashboards', 'datasets', + 'pages', 'flows', 'settings', 'metadataForms', 'settingsCommon', + ]); + return mapCollection(stack, 'translations', (entry, path) => { + // A `translation` ITEM, not a bundle — `settings` is still declared + // there. Leave it whole. + if ('locale' in entry) return entry; + let next = entry; + for (const [locale, data] of Object.entries(entry)) { + if (!isDict(data) || !isDict(data.settings)) continue; + if (!Object.keys(data).every((k) => GROUPS.has(k))) continue; + const stripped = stripKeys(data, ['settings'], emit, `${path}.${locale}`); + if (stripped === data) continue; + next = next === entry ? { ...entry } : next; + next[locale] = stripped; + } + return next; + }); + }, + fixture: { + before: { + translations: [ + { + 'zh-CN': { + settings: { mail: { title: '邮件投递', keys: { host: { label: '主机' } } } }, + // A neighbouring group on the same entry rides through untouched. + apps: { crm: { label: '客户关系管理' } }, + }, + }, + ], + }, + after: { + translations: [ + { + 'zh-CN': { + apps: { crm: { label: '客户关系管理' } }, + }, + }, + ], + }, + // One per stripped group: the single `zh-CN` entry. + expectedNotices: 1, + }, +}; + /** * `translation.pages..components..submitLabel` — the component-copy * key retired with its only declarer (protocol 18, #10926, ADR-0049). @@ -10060,6 +10159,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly.settings — the per-app bundle’s settings group', + // The group names are DERIVED from `TranslationDataSchema.shape`, never typed + // out beside it. A hand-maintained copy of a schema's key set is the construct + // that drifted to nine-of-ten in this very message, so the copy is deleted + // rather than pinned: there is one spelling of the set, and a group added to + // the per-app face reaches this sentence the day it is declared. + // `Object.keys` on a zod object shape yields the declaration order of the + // literal it was built from — the order this sentence promises the operator. + // A getter, not an eager template: importing the registry must not force the + // lazy translation schema at module load. + get replacement(): string { + const groups = Object.keys(TranslationDataSchema.shape); + return 'Delete the group from the per-app bundle. There is no per-app replacement key: settings copy ' + + 'is not application-authorable at all. `settings` is keyed by `SettingsManifest.namespace`, ' + + 'and only platform code declares a manifest ' + + '(`packages/services/service-settings/src/manifests/*.manifest.ts`), so the only namespaces a ' + + 'per-app entry could ever address were the platform’s own. Platform settings copy is ' + + 'translated in the PLATFORM bundle — `@objectstack/service-settings`’s ' + + '`settingsBuiltinTranslations`, typed `PlatformTranslationData` — which is where a correction ' + + 'to a platform string belongs. An application’s own copy goes in the ' + + `${groups.length} groups the per-app bundle still declares, in the order it declares them: ` + + groups.map((g) => `\`${g}\``).join(', ') + + '. Note `settingsCommon` among them: it IS on this face, so the Settings UI shell strings an ' + + 'application may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT ' + + 'what is being removed here — only the per-namespace manifest copy under `settings` is.'; + }, + reason: + 'Not losslessly convertible, and NOT because the content was inert — but not because it ' + + 'overrode anything either. Measured on this tree before the split: ' + + '`AppPlugin.loadTranslations` hands each `stack.translations` bundle entry WHOLE to ' + + '`II18nService.loadTranslations`, the adapter deep-merges it into the one per-locale tree, and ' + + 'every platform plugin contributes into that same tree — so `settings` from an app bundle and ' + + '`settings` from `@objectstack/service-settings` land in one place. `resolveSettingsTitle` and ' + + 'the rest of the `resolveSettings*` family read it (`pickSettingsEntry` → ' + + "`pickData(bundle, locale)?.settings`), and so does the console's `useSettingsLabel`, which " + + 'scans every namespace carrying a `settings` branch; the liveness ledger ' + + '`packages/spec/liveness/translation.json` records that reader with its evidence pointer. ' + + 'ORDER decides the rest, and it runs against the application: `AppPlugin` loads the app’s ' + + 'bundles in its own `start()` (kernel Phase 2), `SettingsServicePlugin` contributes the ' + + 'platform’s settings translations from a `kernel:ready` hook (Phase 3), and `deepMerge` gives ' + + 'the LATER source the leaf — `AppPlugin`’s own comment says as much (“the platform bundles have ' + + 'not arrived yet at this point in the lifecycle”). So the platform won every key both bundles ' + + 'defined, and what an application actually had was a GAP FILLER on a namespace it does not own: ' + + 'the entry rendered only where the platform bundle carried no string for that key and locale ' + + '(the platform ships en / zh-CN / ja-JP / es-ES), silently, with no way for the author to tell ' + + 'a filled gap from an ignored override. Dropping the group therefore takes those gaps back to ' + + 'the manifest’s own literal — the `?? fallback` every `resolveSettings*` helper ends in, which ' + + 'is English — and that is a VISIBLE change to what a Settings screen renders, not a no-op, ' + + 'which a mechanical notice reading "(removed)" does not convey. The two bundles are separate ' + + 'namespaces from this major on (ruling batch #132 item 2 letter ②, 2026-09-13; ADR-0049 ' + + 'enforce-or-remove supplied the question, not the answer — the maintainer struck the card’s own ' + + 'removal disposition, because `settings` is a LIVE platform key). No deprecation window: the ' + + 'per-app door refuses the key by name from this major, with the prescription on the rejection.', + acceptanceCriteria: + 'No per-app bundle carries `settings`: `defineTranslationBundle({ : { settings: … } })` ' + + 'and a `defineStack({ translations: [...] })` entry carrying it are both refused as an ' + + 'unrecognized key, and the refusal names the group as platform-only rather than suggesting a ' + + 'rename (pinned in `packages/spec/src/system/translation.test.ts`). The platform face still ' + + 'accepts it: `PlatformTranslationDataSchema.parse({ settings: … })` succeeds, ' + + '`settingsBuiltinTranslations` still type-checks, and `GET /api/v1/i18n/translations/:locale` ' + + 'still declares `settings` on its response (`GetTranslationsResponseSchema`), because the ' + + 'served document is the merged tree. The registered `translation` metadata type is unchanged ' + + 'and still declares `settings`. For a deployment that WAS authoring per-app settings copy: the ' + + 'screens to re-read after the upgrade are the ones where it was FILLING A GAP — a namespace, ' + + 'key or locale the platform bundle does not translate — because those now render the ' + + 'manifest’s own literal, which is English. Everywhere the platform already carried the string, ' + + 'nothing changes on screen: the platform value was already the one being served. If a platform ' + + 'string is wrong or missing for your locale, correct it in the platform bundle ' + + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add the ' + + 'app-side copy, which the platform overwrites on every boot wherever it has its own value.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 213a29821f6..f6c5ee6c3c2 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -38,6 +38,13 @@ * hand-written and still merges as text. */ +// A VALUE import, and the only one here: an entry literal below derives its +// group enumeration from this schema's own keys rather than restating them +// (`translation-per-app-settings-platform-only`). Entry files carry their own +// copy of this import, but the generator treats a file's imports as scaffolding +// and concatenates only the literal — so an entry that references a value needs +// that value in scope HERE, hand-written, outside the generated regions. +import { TranslationDataSchema } from '../system/translation.zod.js'; import type { MigrationStep } from './types.js'; /** @@ -5211,7 +5218,20 @@ const step18: MigrationStep = { + 'dashboard widgets only — `ReportChartSchema` and the inline-data react `` ' + 'tier keep their own axes — and the paired semantic entry carries what the stripped ' + 'keys were saying, because an authored axis field may name a column the widget never ' - + 'selected and no walker can move that intent into the dataset.', + + 'selected and no walker can move that intent into the dataset. ' + + 'Finally, it splits the translation bundle type in two (#15178, ruling batch #132 item 2 ' + + 'letter ②): the platform bundle keeps all eleven groups and the per-app bundle ' + + '(`stack.translations`, `defineTranslationBundle`) no longer declares `settings`, which is ' + + 'keyed by `SettingsManifest.namespace` and only platform code declares a manifest. Both ' + + 'bundles load into ONE served tree, so an app-authored `settings` branch did not sit inert — ' + + 'but nor did it override the platform: the app’s bundles arrive in `AppPlugin`’s `start()` ' + + '(Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later ' + + 'source the leaf, so what an application had was a GAP FILLER on a namespace it does not own ' + + '— rendering only where the platform bundle carried no string for that key and locale. The ' + + 'D2 conversion strips the group from per-app bundle entries only (never from a `translation` ' + + 'ITEM, which still declares it), and the paired semantic entry says what the strip means, ' + + 'because a notice reading "(removed)" does not say that those gaps fall back to the ' + + "manifest's own English literal.", conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5244,6 +5264,7 @@ const step18: MigrationStep = { 'page-assigned-profiles-removed', 'chart-config-aria-removed', 'dashboard-widget-chart-config-structure-removed', + 'translation-per-app-settings-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -12410,6 +12431,84 @@ const step18: MigrationStep = { + 'and must be verified as such: nothing ever parsed or read these shapes, so removing ' + 'them removes no behaviour.', }, + // The judgment half of `translation-per-app-settings-removed`. The D2 + // conversion deletes the group mechanically; what it cannot say in a + // `to: '(removed)'` notice is WHERE those strings were rendering — only in the + // gaps the platform's own bundle left — and that deleting them sends those + // gaps back to the manifest's English literal. + { + id: 'translation-per-app-settings-platform-only', + surface: 'stack.translations[]..settings — the per-app bundle’s settings group', + // The group names are DERIVED from `TranslationDataSchema.shape`, never typed + // out beside it. A hand-maintained copy of a schema's key set is the construct + // that drifted to nine-of-ten in this very message, so the copy is deleted + // rather than pinned: there is one spelling of the set, and a group added to + // the per-app face reaches this sentence the day it is declared. + // `Object.keys` on a zod object shape yields the declaration order of the + // literal it was built from — the order this sentence promises the operator. + // A getter, not an eager template: importing the registry must not force the + // lazy translation schema at module load. + get replacement(): string { + const groups = Object.keys(TranslationDataSchema.shape); + return 'Delete the group from the per-app bundle. There is no per-app replacement key: settings copy ' + + 'is not application-authorable at all. `settings` is keyed by `SettingsManifest.namespace`, ' + + 'and only platform code declares a manifest ' + + '(`packages/services/service-settings/src/manifests/*.manifest.ts`), so the only namespaces a ' + + 'per-app entry could ever address were the platform’s own. Platform settings copy is ' + + 'translated in the PLATFORM bundle — `@objectstack/service-settings`’s ' + + '`settingsBuiltinTranslations`, typed `PlatformTranslationData` — which is where a correction ' + + 'to a platform string belongs. An application’s own copy goes in the ' + + `${groups.length} groups the per-app bundle still declares, in the order it declares them: ` + + groups.map((g) => `\`${g}\``).join(', ') + + '. Note `settingsCommon` among them: it IS on this face, so the Settings UI shell strings an ' + + 'application may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT ' + + 'what is being removed here — only the per-namespace manifest copy under `settings` is.'; + }, + reason: + 'Not losslessly convertible, and NOT because the content was inert — but not because it ' + + 'overrode anything either. Measured on this tree before the split: ' + + '`AppPlugin.loadTranslations` hands each `stack.translations` bundle entry WHOLE to ' + + '`II18nService.loadTranslations`, the adapter deep-merges it into the one per-locale tree, and ' + + 'every platform plugin contributes into that same tree — so `settings` from an app bundle and ' + + '`settings` from `@objectstack/service-settings` land in one place. `resolveSettingsTitle` and ' + + 'the rest of the `resolveSettings*` family read it (`pickSettingsEntry` → ' + + "`pickData(bundle, locale)?.settings`), and so does the console's `useSettingsLabel`, which " + + 'scans every namespace carrying a `settings` branch; the liveness ledger ' + + '`packages/spec/liveness/translation.json` records that reader with its evidence pointer. ' + + 'ORDER decides the rest, and it runs against the application: `AppPlugin` loads the app’s ' + + 'bundles in its own `start()` (kernel Phase 2), `SettingsServicePlugin` contributes the ' + + 'platform’s settings translations from a `kernel:ready` hook (Phase 3), and `deepMerge` gives ' + + 'the LATER source the leaf — `AppPlugin`’s own comment says as much (“the platform bundles have ' + + 'not arrived yet at this point in the lifecycle”). So the platform won every key both bundles ' + + 'defined, and what an application actually had was a GAP FILLER on a namespace it does not own: ' + + 'the entry rendered only where the platform bundle carried no string for that key and locale ' + + '(the platform ships en / zh-CN / ja-JP / es-ES), silently, with no way for the author to tell ' + + 'a filled gap from an ignored override. Dropping the group therefore takes those gaps back to ' + + 'the manifest’s own literal — the `?? fallback` every `resolveSettings*` helper ends in, which ' + + 'is English — and that is a VISIBLE change to what a Settings screen renders, not a no-op, ' + + 'which a mechanical notice reading "(removed)" does not convey. The two bundles are separate ' + + 'namespaces from this major on (ruling batch #132 item 2 letter ②, 2026-09-13; ADR-0049 ' + + 'enforce-or-remove supplied the question, not the answer — the maintainer struck the card’s own ' + + 'removal disposition, because `settings` is a LIVE platform key). No deprecation window: the ' + + 'per-app door refuses the key by name from this major, with the prescription on the rejection.', + acceptanceCriteria: + 'No per-app bundle carries `settings`: `defineTranslationBundle({ : { settings: … } })` ' + + 'and a `defineStack({ translations: [...] })` entry carrying it are both refused as an ' + + 'unrecognized key, and the refusal names the group as platform-only rather than suggesting a ' + + 'rename (pinned in `packages/spec/src/system/translation.test.ts`). The platform face still ' + + 'accepts it: `PlatformTranslationDataSchema.parse({ settings: … })` succeeds, ' + + '`settingsBuiltinTranslations` still type-checks, and `GET /api/v1/i18n/translations/:locale` ' + + 'still declares `settings` on its response (`GetTranslationsResponseSchema`), because the ' + + 'served document is the merged tree. The registered `translation` metadata type is unchanged ' + + 'and still declares `settings`. For a deployment that WAS authoring per-app settings copy: the ' + + 'screens to re-read after the upgrade are the ones where it was FILLING A GAP — a namespace, ' + + 'key or locale the platform bundle does not translate — because those now render the ' + + 'manifest’s own literal, which is English. Everywhere the platform already carried the string, ' + + 'nothing changes on screen: the platform value was already the one being served. If a platform ' + + 'string is wrong or missing for your locale, correct it in the platform bundle ' + + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add the ' + + 'app-side copy, which the platform overwrites on every boot wherever it has its own value.', + }, { id: 'ui-action-undoable-unfulfillable-refused', surface: '`action` documents declaring `undoable: true` on a shape no runtime fulfils — ' diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 39d432d69fc..755544466a0 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -57,7 +57,11 @@ import { mapFlowNodeList } from '../conversions/walk.js'; -import type { TranslationBundle, TranslationData } from './translation.zod'; +import type { + PlatformTranslationBundle, + TranslationBundle, + TranslationData, +} from './translation.zod'; /** * Minimal view shape consumed by `resolveViewLabel`. @@ -310,10 +314,10 @@ export function resolveBundleLocale( return undefined; } -function pickData( - bundle: TranslationBundle | undefined, +function pickData( + bundle: Record | undefined, locale: string, -): TranslationData | undefined { +): D | undefined { if (!bundle) return undefined; const exact = bundle[locale]; if (exact !== undefined) return exact; @@ -2664,7 +2668,7 @@ export function translateObject( // ──────────────────────────────────────────────────────────────────────────── function pickSettingsEntry( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, locale: string, ) { @@ -2672,7 +2676,7 @@ function pickSettingsEntry( } function resolveOptionalString( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, pick: (entry: NonNullable>) => string | undefined, opts?: ResolveOptions, @@ -2689,7 +2693,7 @@ function resolveOptionalString( /** Resolve manifest title; falls back to literal. */ export function resolveSettingsTitle( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, fallback: string, opts?: ResolveOptions, @@ -2699,7 +2703,7 @@ export function resolveSettingsTitle( /** Resolve manifest description. Returns literal (possibly undefined) when no translation found. */ export function resolveSettingsDescription( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, fallback: string | undefined, opts?: ResolveOptions, @@ -2709,7 +2713,7 @@ export function resolveSettingsDescription( /** Resolve a group title under `settings..groups..title`. */ export function resolveSettingsGroupTitle( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, groupKey: string, fallback: string, @@ -2722,7 +2726,7 @@ export function resolveSettingsGroupTitle( } export function resolveSettingsGroupDescription( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, groupKey: string, fallback: string | undefined, @@ -2736,7 +2740,7 @@ export function resolveSettingsGroupDescription( /** Resolve a setting field label under `settings..keys..label`. */ export function resolveSettingsFieldLabel( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, key: string, fallback: string, @@ -2748,7 +2752,7 @@ export function resolveSettingsFieldLabel( } export function resolveSettingsFieldHelp( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, key: string, fallback: string | undefined, @@ -2760,7 +2764,7 @@ export function resolveSettingsFieldHelp( } export function resolveSettingsFieldPlaceholder( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, key: string, fallback: string | undefined, @@ -2774,7 +2778,7 @@ export function resolveSettingsFieldPlaceholder( /** Resolve an enum option label under `settings..keys..options.`. */ export function resolveSettingsOptionLabel( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, key: string, optionValue: string, @@ -2793,7 +2797,7 @@ export function resolveSettingsOptionLabel( /** Resolve an action button label under `settings..actions..label`. */ export function resolveSettingsActionLabel( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, actionId: string, fallback: string, @@ -2806,7 +2810,7 @@ export function resolveSettingsActionLabel( } export function resolveSettingsActionConfirm( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, actionId: string, fallback: string | undefined, @@ -2819,7 +2823,7 @@ export function resolveSettingsActionConfirm( } export function resolveSettingsActionSuccess( - bundle: TranslationBundle | undefined, + bundle: PlatformTranslationBundle | undefined, namespace: string, actionId: string, fallback: string | undefined, diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index f64a71d993a..dd6baecda7e 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -6,6 +6,9 @@ import { ScreenConfigSchema, ScreenFieldConfigSchema } from '../automation/built import { TranslationDataSchema, TranslationBundleSchema, + PlatformTranslationDataSchema, + PlatformTranslationBundleSchema, + defineTranslationBundle, LocaleSchema, FieldTranslationSchema, ObjectTranslationDataSchema, @@ -786,7 +789,6 @@ describe('translation unknown-key strictness (#4001)', () => { ['a page translation', { pages: { home: { subtitel: 'Welcome' } } }, 'subtitle'], ['a dashboard widget', { dashboards: { sales: { widgets: { rev: { titel: 'Revenue' } } } } }, 'title'], ['a dashboard global filter', { dashboards: { sales: { globalFilters: { region: { lable: 'Region' } } } } }, 'label'], - ['a settings key', { settings: { mail: { keys: { host: { lable: 'Host' } } } } }, 'label'], ['a metadata form field', { metadataForms: { object: { fields: { name: { helpTxt: 'x' } } } } }, 'helpText'], ])('rejects a typo in %s and names the key it meant', (_what, body, expected) => { const result = TranslationDataSchema.safeParse(body); @@ -795,6 +797,19 @@ describe('translation unknown-key strictness (#4001)', () => { .toContain(`→ \`${expected}\``); }); + it.each([ + ['a settings key', { settings: { mail: { keys: { host: { lable: 'Host' } } } } }, 'label'], + ])('rejects a typo in %s on the PLATFORM face and names the key it meant', (_what, body, expected) => { + // Same probe as the per-app battery above, moved rather than deleted: the + // group left the per-app door with #15178, so the per-app schema answers + // `settings` itself now (the battery two blocks down) and the nested-key + // suggester can only be exercised where the group still exists. + const result = PlatformTranslationDataSchema.safeParse(body); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain(`\u2192 \`${expected}\``); + }); + it.each([ ['object', { objects: { account: { views: {} } } }, '_views'], ['object', { objects: { account: { actions: {} } } }, '_actions'], @@ -1259,8 +1274,10 @@ describe('translation unknown-key strictness (#4001)', () => { }); it('still accepts every declared group together', () => { - // The shape is spread into two schemas (bundle entry + metadata item); this - // is the guard against closing one of them against a stale key list. + // The shape is spread into three schemas (per-app bundle entry, platform + // bundle entry, metadata item); this is the guard against closing one of + // them against a stale key list. `settings` is the one group the per-app + // face does NOT take, so it is authored separately below. const body = { objects: { account: { label: 'Account', _views: { all: { label: 'All', emptyState: { title: 'None' } } } } }, apps: { crm: { label: 'CRM', navigation: { sales: { label: 'Sales' } } } }, @@ -1269,12 +1286,56 @@ describe('translation unknown-key strictness (#4001)', () => { dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue', subCaption: 'vs last quarter' } }, globalFilters: { region: { label: 'Region', options: { emea: 'EMEA' } } } } }, pages: { home: { label: 'Home', title: 'Welcome' } }, flows: { lead_conversion: { label: 'Convert Lead', screens: { details: { title: 'Details', fields: { name: { label: 'Name', placeholder: 'Enter a name' } } } } } }, - settings: { mail: { title: 'Mail', keys: { host: { label: 'Host' } } } }, metadataForms: { object: { label: 'Object', fields: { name: { label: 'Name' } } } }, settingsCommon: { sourceLabels: { env: 'Env', tenant: 'Tenant' } }, }; + const settings = { mail: { title: 'Mail', keys: { host: { label: 'Host' } } } }; expect(() => TranslationDataSchema.parse(body)).not.toThrow(); - expect(() => TranslationItemSchema.parse({ locale: 'en', ...body })).not.toThrow(); + expect(() => PlatformTranslationDataSchema.parse({ ...body, settings })).not.toThrow(); + expect(() => TranslationItemSchema.parse({ locale: 'en', ...body, settings })).not.toThrow(); + }); + + // ────────────────────────────────────────────────────────────────────────── + // #15178 — `settings` is a PLATFORM group; the per-app door refuses it + // ────────────────────────────────────────────────────────────────────────── + describe('per-app `settings` is refused with the platform-only prescription (#15178)', () => { + const settings = { mail: { title: 'Mail', keys: { host: { label: 'Host' } } } }; + + it.each([ + ['`settings`', 'settings'], + // The singular used to be an ALIAS for `settings`; an alias whose target + // the shape no longer accepts is a suggestion the author cannot take, so + // it rides the same prescription instead. + ['the singular `setting`', 'setting'], + ])('refuses %s on a per-app bundle entry', (_what, key) => { + const result = TranslationDataSchema.safeParse({ [key]: settings }); + expect(result.success).toBe(false); + const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? ''; + // The prescription, not a rename: naming a key this door rejects would + // send the author into a second rejection. + expect(message).toContain('PLATFORM group'); + expect(message).toContain('PlatformTranslationData'); + expect(message).not.toContain(`\`${key}\` \u2192`); + }); + + it('refuses it at the bundle door too, which is the one an app actually authors', () => { + // `defineTranslationBundle` and `stack.translations` both parse through + // `TranslationBundleSchema`; #3778's asymmetry was a guard that closed + // one door and left the other open. + const result = TranslationBundleSchema.safeParse({ 'zh-CN': { settings } }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? '') + .toContain('PLATFORM group'); + expect(() => defineTranslationBundle({ 'zh-CN': { settings } } as never)).toThrow(/PLATFORM group/s); + }); + + it('still accepts it on the platform face and on the registered `translation` item', () => { + // The over-acceptance control for the refusals above: the group did not + // leave the contract, it left ONE of its three faces. + expect(() => PlatformTranslationDataSchema.parse({ settings })).not.toThrow(); + expect(() => PlatformTranslationBundleSchema.parse({ 'zh-CN': { settings } })).not.toThrow(); + expect(() => TranslationItemSchema.parse({ locale: 'zh-CN', settings })).not.toThrow(); + }); }); it.each(['fileOrganization', 'messageFormat', 'lazyLoad', 'cache'])( diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index a0ae44a6894..fb7ad26f0a9 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -562,6 +562,45 @@ const TRANSLATION_KEY_GUIDANCE: Record = { + ...TRANSLATION_KEY_GUIDANCE, + settings: PER_APP_SETTINGS_PLATFORM_ONLY, + setting: PER_APP_SETTINGS_PLATFORM_ONLY, +}; + // ──────────────────────────────────────────────────────────────────────────── // Locale-level Translation Data (per-locale aggregate) // ──────────────────────────────────────────────────────────────────────────── @@ -586,18 +625,21 @@ const TRANSLATION_KEY_GUIDANCE: Record.fields..options` uses — cannot address them unambiguously. Author the ' + "option labels on the node's `config`."; -const translationDataShape = () => ({ +const appTranslationDataShape = () => ({ /** Object translations */ objects: z.record(z.string(), ObjectTranslationDataSchema).optional().describe('Object translations keyed by object name'), @@ -1153,67 +1195,6 @@ const translationDataShape = () => ({ })).optional().describe('Screen translations keyed by screen node id (`FlowNode.id`, the client\'s `ScreenSpec.nodeId`)'), })).optional().describe('Screen-flow translations keyed by flow name'), - /** - * Settings manifest translations keyed by settings namespace - * (matches `SettingsManifest.namespace`, e.g. "mail", "branding"). - * - * Convention (auto-resolved by `resolveSettings*` helpers): - * settings..title - * settings..description - * settings..groups..title - * settings..groups..description - * settings..keys..label - * settings..keys..help - * settings..keys..placeholder - * settings..keys..options. - * settings..actions..label - * settings..actions..confirmText - * settings..actions..successMessage - */ - settings: z.record(z.string(), strictObject({ - surface: 'this settings manifest translation', - history: TRANSLATION_HISTORY, - aliases: { label: 'title', name: 'title', sections: 'groups', fields: 'keys', settings: 'keys' }, - }, { - title: z.string().optional().describe('Translated settings manifest title'), - description: z.string().optional().describe('Translated settings manifest description'), - groups: z.record(z.string(), strictObject({ - surface: 'this settings group translation', - history: TRANSLATION_HISTORY, - aliases: { label: 'title', name: 'title', heading: 'title' }, - }, { - title: z.string().optional().describe('Translated group title'), - description: z.string().optional().describe('Translated group description'), - })).optional().describe('Group translations keyed by group key'), - keys: z.record(z.string(), strictObject({ - surface: 'this setting translation', - history: TRANSLATION_HISTORY, - aliases: { title: 'label', name: 'label', helpText: 'help', hint: 'help', description: 'help', choices: 'options', values: 'options' }, - }, { - label: z.string().optional().describe('Translated setting label'), - help: z.string().optional().describe('Translated setting help text'), - placeholder: z.string().optional().describe('Translated input placeholder'), - options: z.record(z.string(), z.string()).optional() - .describe('Enum option value → translated label'), - })).optional().describe('Per-setting field translations keyed by setting key'), - actions: z.record(z.string(), strictObject({ - surface: 'this settings action translation', - history: TRANSLATION_HISTORY, - // Settings actions have no `params`/`resultDialog` — a settings button is - // not an action-metadata action. Say so, rather than suggesting the - // nearest key and sending the author round again. - aliases: { name: 'label', title: 'label', confirm: 'confirmText', success: 'successMessage' }, - guidance: { - params: 'settings actions take no parameters — there is no `params` group to translate', - resultDialog: 'settings actions have no result dialog — `resultDialog` translations belong under `objects.._actions` or `globalActions`', - }, - }, { - label: z.string().optional().describe('Translated action label'), - confirmText: z.string().optional().describe('Translated confirmation prompt'), - successMessage: z.string().optional().describe('Translated success toast/message'), - })).optional().describe('Action button translations keyed by action id'), - })).optional().describe('Settings manifest translations keyed by namespace'), - /** * Translations for **metadata-type configuration forms** — the forms * used by admins to author objects, fields, agents, flows, etc. in the @@ -1312,27 +1293,184 @@ const translationDataShape = () => ({ }).optional().describe('Cross-namespace Settings UI strings'), }); +/** + * The one group that is PLATFORM-ONLY, as a shape rather than a schema. + * + * `settings` is keyed by `SettingsManifest.namespace`, and a manifest is + * platform code (`packages/services/service-settings/src/manifests/*.manifest.ts`) + * — an application cannot declare one. So the only namespaces a per-app bundle + * could ever address are the PLATFORM's own, and what an app authored there + * deep-merged into the single served tree + * (`AppPlugin.loadTranslations` → `II18nService.loadTranslations`) that + * {@link resolveSettingsTitle} and the console's `useSettingsLabel` read. + * + * It was therefore not dropped — but nor did it win. `AppPlugin` loads the + * app's bundles in its own `start()` (kernel Phase 2) while + * `SettingsServicePlugin` contributes the platform's settings translations from + * a `kernel:ready` hook (Phase 3), and `deepMerge` gives the LATER source the + * leaf. So a per-app entry rendered only where the platform bundle carried no + * string for that key and locale, and was overwritten wherever both defined the + * key. Platform labels and application labels are separate namespaces (ruling + * batch #132 item 2 letter ②), so this shape is spread into + * {@link PlatformTranslationDataSchema} and {@link TranslationItemSchema} and + * NOT into {@link TranslationDataSchema}, whose door refuses it by name. + * + * A function, not a `const`, for the same reason + * {@link appTranslationDataShape} is one. + */ +const platformSettingsShape = () => ({ + /** + * Settings manifest translations keyed by settings namespace + * (matches `SettingsManifest.namespace`, e.g. "mail", "branding"). + * + * Convention (auto-resolved by `resolveSettings*` helpers): + * settings..title + * settings..description + * settings..groups..title + * settings..groups..description + * settings..keys..label + * settings..keys..help + * settings..keys..placeholder + * settings..keys..options. + * settings..actions..label + * settings..actions..confirmText + * settings..actions..successMessage + */ + settings: z.record(z.string(), strictObject({ + surface: 'this settings manifest translation', + history: TRANSLATION_HISTORY, + aliases: { label: 'title', name: 'title', sections: 'groups', fields: 'keys', settings: 'keys' }, + }, { + title: z.string().optional().describe('Translated settings manifest title'), + description: z.string().optional().describe('Translated settings manifest description'), + groups: z.record(z.string(), strictObject({ + surface: 'this settings group translation', + history: TRANSLATION_HISTORY, + aliases: { label: 'title', name: 'title', heading: 'title' }, + }, { + title: z.string().optional().describe('Translated group title'), + description: z.string().optional().describe('Translated group description'), + })).optional().describe('Group translations keyed by group key'), + keys: z.record(z.string(), strictObject({ + surface: 'this setting translation', + history: TRANSLATION_HISTORY, + aliases: { title: 'label', name: 'label', helpText: 'help', hint: 'help', description: 'help', choices: 'options', values: 'options' }, + }, { + label: z.string().optional().describe('Translated setting label'), + help: z.string().optional().describe('Translated setting help text'), + placeholder: z.string().optional().describe('Translated input placeholder'), + options: z.record(z.string(), z.string()).optional() + .describe('Enum option value → translated label'), + })).optional().describe('Per-setting field translations keyed by setting key'), + actions: z.record(z.string(), strictObject({ + surface: 'this settings action translation', + history: TRANSLATION_HISTORY, + // Settings actions have no `params`/`resultDialog` — a settings button is + // not an action-metadata action. Say so, rather than suggesting the + // nearest key and sending the author round again. + aliases: { name: 'label', title: 'label', confirm: 'confirmText', success: 'successMessage' }, + guidance: { + params: 'settings actions take no parameters — there is no `params` group to translate', + resultDialog: 'settings actions have no result dialog — `resultDialog` translations belong under `objects.._actions` or `globalActions`', + }, + }, { + label: z.string().optional().describe('Translated action label'), + confirmText: z.string().optional().describe('Translated confirmation prompt'), + successMessage: z.string().optional().describe('Translated success toast/message'), + })).optional().describe('Action button translations keyed by action id'), + })).optional().describe('Settings manifest translations keyed by namespace'), +}); + +/** + * One locale of a PER-APP translation bundle — `stack.translations`, and + * everything {@link defineTranslationBundle} builds. + * + * Ten groups: every group the platform bundle declares EXCEPT `settings`, + * which is platform-only and is refused here by name with + * {@link PER_APP_SETTINGS_PLATFORM_ONLY} as the remedy. See + * {@link PlatformTranslationDataSchema} for the eleven-group face and for why + * the two are separate namespaces. + */ export const TranslationDataSchema = lazySchema(() => strictObject({ surface: 'this locale of the translation bundle', history: TRANSLATION_HISTORY, - guidance: TRANSLATION_KEY_GUIDANCE, - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + guidance: APP_TRANSLATION_KEY_GUIDANCE, + // ⛔ No `setting: 'settings'` alias any more: this door no longer declares + // `settings`, and an alias prescribing a key the shape rejects is a + // suggestion the author cannot take (the `alias-integrity` audit judges + // exactly that). Both spellings are answered by `guidance` above instead. + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', flow: 'flows', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, // `locale` lives on the ITEM, not on a bundle entry (the bundle keys ARE the // locales). Naming it keeps the suggestion useful for an author who moved a // `translation` item into a bundle and left the field behind. extraKeys: ['locale'], -}, translationDataShape()).describe('Translation data for objects, apps, and UI messages')); +}, appTranslationDataShape()).describe('Per-app translation data for objects, apps, and UI messages')); export type TranslationData = z.input; +/** + * One locale of a PLATFORM translation bundle — the eleven groups, `settings` + * included. + * + * The platform's own bundles are code, not authored metadata + * (`@objectstack/service-settings`, `@objectstack/platform-objects`, the + * plugins), and `settings` is theirs alone: it is keyed by + * `SettingsManifest.namespace` and only platform code declares a manifest. + * + * ## Why this is a second export rather than one type serving both + * + * It was one type, and one type serving two bundles is what made every + * reading of this key wrong. A per-app census asked "does any application + * carry `settings` data?", got zero, and read that as "dead key" — while the + * platform read it on every Settings screen. The two bundles are separate + * namespaces on the platforms this one resembles, and they are separate here + * (ruling batch #132 item 2 letter ②, 2026-09-13). + * + * What the merged type cost, measured rather than assumed: both bundles are + * loaded into ONE served tree (`AppPlugin.loadTranslations` and each platform + * plugin's `kernel:ready` contribution both call + * `II18nService.loadTranslations`, which deep-merges), and + * {@link resolveSettingsTitle} and the console's `useSettingsLabel` read that + * merged tree. So a per-app `settings` branch did not sit inert — but it did + * not override the platform either. The app's bundles arrive in `AppPlugin`'s + * `start()` (kernel Phase 2) and the platform's at `kernel:ready` (Phase 3), + * and `deepMerge` gives the later source the leaf, so the platform won every + * key both defined. What an application actually had was a GAP FILLER on a + * namespace it does not own: it rendered only where the platform bundle + * carried no string for that key and locale, silently and with no way to tell + * the two cases apart. + */ +export const PlatformTranslationDataSchema = lazySchema(() => strictObject({ + surface: 'this locale of the platform translation bundle', + history: TRANSLATION_HISTORY, + guidance: TRANSLATION_KEY_GUIDANCE, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + extraKeys: ['locale'], +}, { + ...appTranslationDataShape(), + ...platformSettingsShape(), +}).describe('Platform translation data — the per-app groups plus the platform-only `settings`')); + +export type PlatformTranslationData = z.input; + // ──────────────────────────────────────────────────────────────────────────── // Translation Bundle (all locales) // ──────────────────────────────────────────────────────────────────────────── -export const TranslationBundleSchema = lazySchema(() => z.record(LocaleSchema, TranslationDataSchema).describe('Map of locale codes to translation data')); +export const TranslationBundleSchema = lazySchema(() => z.record(LocaleSchema, TranslationDataSchema).describe('Map of locale codes to per-app translation data')); export type TranslationBundle = z.input; +/** + * The platform's own locale map — {@link PlatformTranslationDataSchema} per + * locale. `@objectstack/service-settings`'s `settingsBuiltinTranslations` is + * the bundle that needs it; a platform package authoring only groups the + * per-app face also declares can keep the narrower {@link TranslationBundle}. + */ +export const PlatformTranslationBundleSchema = lazySchema(() => z.record(LocaleSchema, PlatformTranslationDataSchema).describe('Map of locale codes to platform translation data')); + +export type PlatformTranslationBundle = z.input; + /** * Type-safe factory for an i18n translation bundle (locale code → translations map). Validates at authoring time via * `.parse()` and accepts input-shape config (optional defaults, CEL @@ -1448,7 +1586,8 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ guidance: TRANSLATION_KEY_GUIDANCE, aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, }, { - ...translationDataShape(), + ...appTranslationDataShape(), + ...platformSettingsShape(), locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), // Item identity. Every other registered metadata type declares these; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 41c0adefeb1..5620656d047 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 787 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 789 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1259,6 +1259,11 @@ export type Iso651 = Assert, z.infer< typeof M152.ObjectTranslationDataSchema > >>; export type Iso653 = Assert, z.infer< typeof M152.TranslationDataSchema > >>; export type Iso654 = Assert, z.infer< typeof M152.TranslationBundleSchema > >>; +// #15178 split the bundle type in two. The platform face is the per-app shape +// plus one more optional group, so its two states coincide exactly as the +// per-app face's do — pinned rather than given a permanent `XParsed` synonym. +export type Iso880 = Assert, z.infer< typeof M152.PlatformTranslationDataSchema > >>; +export type Iso881 = Assert, z.infer< typeof M152.PlatformTranslationBundleSchema > >>; export type Iso655 = Assert, z.infer< typeof M152.TranslationConfigSchema > >>; export type Iso656 = Assert, z.infer< typeof M152.TranslationItemSchema > >>; export type Iso657 = Assert, z.infer< typeof M152.TranslationDiffStatusSchema > >>; @@ -1691,7 +1696,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 787 isomorphic pins', () => { + it('still declares all 789 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2283,7 +2288,20 @@ describe('ADR-0122 type-alias convention', () => { // `AnalyticsQuerySchema.shape` — already pinned isomorphic as Iso300 — and // the four it adds carry no default, transform, catch or pipe, so no // `XParsed` is declared and all three come here instead. +3 added. - expect(pins).toHaveLength(787); + // + // 787 -> 789 is #15178's translation bundle split (system/translation.zod.ts, + // module slot M152): `PlatformTranslationDataSchema` and + // `PlatformTranslationBundleSchema`, the (RISE) case twice. The platform + // face is the per-app shape plus one more `.optional()` group and the + // bundle is a `z.record` of it, so neither gains a default or a transform — + // the same reason `TranslationDataSchema` and `TranslationBundleSchema` + // were already on this list, which is what makes their new siblings belong + // here rather than carrying a permanent `XParsed` synonym. +2 added. The + // two movements were authored in parallel off the same 784 and both took + // Iso877/Iso878; #17551 landed on main first, so its ids stand and this + // card's pins renumbered to Iso880/Iso881 — ids are claims about pins, not + // positions, so the renumbering asserts nothing new. + expect(pins).toHaveLength(789); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/scripts/check-i18n-walk-parity.mjs b/scripts/check-i18n-walk-parity.mjs index 3b92b30c95e..046700805cd 100644 --- a/scripts/check-i18n-walk-parity.mjs +++ b/scripts/check-i18n-walk-parity.mjs @@ -55,8 +55,9 @@ * over one fixture that authors a member of every group. The * walker's own output, not a transcription of its header. * ledger the groups that legitimately have no extractor face at all, each - * carrying the REASON. Holds three entries by the 2026-09-04 - * ruling, and only shrinks from there (below). + * carrying the REASON. Filled with three entries by the 2026-09-04 + * ruling and down to two since #15178 paid one off, and it only + * shrinks from there (below). * * and the assertion is two set differences: * @@ -196,10 +197,15 @@ const DECLARED_WATCH_HINTS = [ * it — the ⛔ MAINTAINER-ONLY act the unwalked-group message names, performed * by the owner it names rather than by a landing author getting past a check. * - * `settings` is the one DEFERRAL among the three, and it says so out loud so - * that nobody reads the exemption as the answer: its terminal state — a - * registry-driven emitter on the `metadataForms` precedent, or removal from the - * per-app schema — is held on #15178. + * It held THREE entries until #15178. `settings` was the one DEFERRAL among + * them, and it said so out loud so that nobody read the exemption as the + * answer. Its terminal state landed on ruling batch #132 item 2 letter ② + * (2026-09-13): the translation type split in two, `settings` stayed on the + * PLATFORM bundle and left the per-app one — so the group is no longer + * declared on the schema this gate reads, the exemption went stale by the + * ledger's own `ledger \ declared = ∅` rule, and `LEDGER_CEILING` ratcheted + * 3 → 2 in the same PR. That is the ledger working as designed: the deferral + * was paid down, not renewed. * * Read the class-level note above before adding anything here. The one-line * version: a red belongs to whoever owns the walker, an entry here is a @@ -215,12 +221,6 @@ const KNOWN_NO_EXTRACTOR_FACE = Object.freeze({ 'The Settings UI\'s own five source-badge labels (env / global / tenant / user / default) — the ' + 'console\'s words in every app rather than any app\'s own, ruled out of the per-app bundles on ' + '#7646.', - settings: - 'Keyed by `SettingsManifest.namespace`, and manifests are platform code ' - + '(`packages/services/service-settings/src/manifests/*.manifest.ts`), not authored metadata: no stack ' - + 'config carries them, and no consumer asks for per-app settings translations today. A DEFERRAL, not ' - + 'a fact — the terminal state (a registry-driven emitter on the `metadataForms` precedent, or removal ' - + 'from the per-app schema) is held on #15178.', }); /** @@ -229,7 +229,7 @@ const KNOWN_NO_EXTRACTOR_FACE = Object.freeze({ * act rather than a line nobody re-reads. Shrink-only in both directions — a * ceiling above the real size fails as slack, so it can only be walked down. */ -const LEDGER_CEILING = 3; +const LEDGER_CEILING = 2; /** * A reason must be a real sentence. The floor is 24 characters and a @@ -551,7 +551,7 @@ async function main(wantList) { */ const RECORDED_DECLARED = [ 'apps', 'dashboards', 'datasets', 'flows', 'globalActions', 'messages', - 'metadataForms', 'objects', 'pages', 'settings', 'settingsCommon', + 'metadataForms', 'objects', 'pages', 'settingsCommon', ]; const RECORDED_WALKED = [ 'apps', 'dashboards', 'datasets', 'flows', 'globalActions', 'metadataForms', 'objects', 'pages', @@ -560,7 +560,7 @@ const RECORDED_WALKED = [ * …and the verdict those two produce against an EMPTY ledger — which, since the * 2026-09-04 ruling, is also exactly the shipped ledger's key set. */ -const RECORDED_UNWALKED = ['messages', 'settings', 'settingsCommon']; +const RECORDED_UNWALKED = ['messages', 'settingsCommon']; // ── The self-test's own battery roster and floor (#13489) ────────────────── // diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index 02b5032e6fc..842d8694ed6 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -168,7 +168,9 @@ All translatable content for a single object is aggregated under Top-level groups alongside `objects`: `apps` (label, description, navigation), `messages`, `globalActions` (object-less actions), `dashboards`, `datasets`, `pages`, -`flows`, `settings`, `metadataForms`, `settingsCommon`. +`flows`, `metadataForms`, `settingsCommon`. `settings` is **platform-only** — it is +keyed by a settings manifest's namespace and only the platform declares one, so an +app bundle carrying it is refused by name. For the exact Zod shape (and any field that may have been added since), read `node_modules/@objectstack/spec/src/system/translation.zod.ts` —