From e2d89b0ae82a3a77925b3e69eb3d6b8764bb6664 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 12:31:41 +0000 Subject: [PATCH 01/12] =?UTF-8?q?feat(spec)!:=20split=20the=20translation?= =?UTF-8?q?=20bundle=20type=20=E2=80=94=20`settings`=20is=20platform-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TranslationDataSchema` served two bundles at once (per-app `stack.translations` and the platform packages' own code-authored bundles), and that is what made every reading of `settings` wrong. It now names the PER-APP bundle entry — ten groups, `settings` refused by name with a platform-only remedy — and the new `PlatformTranslationDataSchema` / `PlatformTranslationBundleSchema` carry the eleven-group platform face. Ruling batch #132 item 2 letter ② (2026-09-13). Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../service-settings/src/translations/en.ts | 4 +- .../src/translations/es-ES.ts | 4 +- .../src/translations/index.ts | 7 +- .../src/translations/ja-JP.ts | 4 +- .../src/translations/zh-CN.ts | 4 +- packages/spec/src/api/protocol.zod.ts | 11 +- packages/spec/src/system/i18n-resolver.ts | 38 +-- packages/spec/src/system/translation.zod.ts | 276 +++++++++++++----- scripts/check-i18n-walk-parity.mjs | 30 +- 9 files changed, 255 insertions(+), 123 deletions(-) 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/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/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.zod.ts b/packages/spec/src/system/translation.zod.ts index a0ae44a6894..3a27c26f6a5 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -562,6 +562,37 @@ 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 +617,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 +1187,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 +1285,173 @@ 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 — + * i.e. it silently rewrote the platform's settings copy for that deployment. + * 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 made the merged type actively harmful rather than merely imprecise: + * 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 — it + * overwrote the platform's own settings copy for the deployment, addressed by + * a namespace the application does not own. + */ +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 +1567,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/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) ────────────────── // From 57ff82574e84f987610158611875e77e509c83c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 12:36:53 +0000 Subject: [PATCH 02/12] feat(spec)!: register the per-app `settings` retirement (D2 conversion + ADR-0087 semantic entry) Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- packages/spec/authorable-surface/system.json | 12 ++- .../spec/json-schema.manifest/system.json | 2 + packages/spec/src/conversions/registry.ts | 91 +++++++++++++++++++ ...nslation-per-app-settings-platform-only.ts | 56 ++++++++++++ packages/spec/src/migrations/registry.ts | 65 ++++++++++++- 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts 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/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/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 2371b6f4407..d5401acd477 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7113,6 +7113,96 @@ 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: it overwrote + * the platform's own settings copy for the deployment. Dropping it restores + * the platform's string, which is the ruled intent — and the semantic entry + * `18.translation-per-app-settings-platform-only.ts` is where an author is + * told that is what happened, 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 overwrite the ' + + "platform's own settings copy in the one merged served tree; 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 +10150,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly.settings — the per-app bundle’s settings group', + replacement: + '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 the per-app bundle ' + + 'still declares: `objects`, `apps`, `pages`, `dashboards`, `datasets`, `flows`, ' + + '`globalActions`, `metadataForms`, `messages`.', + reason: + 'Not losslessly convertible, and NOT because the content was inert — the opposite. 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 at ' + + '`kernel:ready` — 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. An app-authored entry therefore RESOLVED, and what it ' + + 'resolved was an override of the platform’s own settings copy for that deployment, addressed ' + + 'by a namespace the application does not own. Dropping the group restores the platform string, ' + + 'which is the ruled intent — but it is a VISIBLE change to what a Settings screen renders, not ' + + 'a no-op, and a mechanical notice reading "(removed)" does not convey that. 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 overriding platform settings copy ' + + 'from an app bundle: after the upgrade the affected Settings screens render the platform’s own ' + + 'strings again — confirm that is what you want, and if a platform string is wrong, correct it ' + + 'in the platform bundle rather than re-adding the app-side override.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f8ecd4f84a4..484d46762c5 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5211,7 +5211,17 @@ 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 ' + + "— it overwrote the platform's own settings copy for that deployment, under a namespace the " + + 'application does not own. 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 a ' + + "platform string is coming back.", conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5244,6 +5254,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 @@ -12216,6 +12227,58 @@ 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 that the strings being deleted were WORKING — + // and that deleting them changes what the deployment renders. + { + id: 'translation-per-app-settings-platform-only', + surface: 'stack.translations[]..settings — the per-app bundle’s settings group', + replacement: + '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 the per-app bundle ' + + 'still declares: `objects`, `apps`, `pages`, `dashboards`, `datasets`, `flows`, ' + + '`globalActions`, `metadataForms`, `messages`.', + reason: + 'Not losslessly convertible, and NOT because the content was inert — the opposite. 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 at ' + + '`kernel:ready` — 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. An app-authored entry therefore RESOLVED, and what it ' + + 'resolved was an override of the platform’s own settings copy for that deployment, addressed ' + + 'by a namespace the application does not own. Dropping the group restores the platform string, ' + + 'which is the ruled intent — but it is a VISIBLE change to what a Settings screen renders, not ' + + 'a no-op, and a mechanical notice reading "(removed)" does not convey that. 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 overriding platform settings copy ' + + 'from an app bundle: after the upgrade the affected Settings screens render the platform’s own ' + + 'strings again — confirm that is what you want, and if a platform string is wrong, correct it ' + + 'in the platform bundle rather than re-adding the app-side override.', + }, // The one key this close DECLARES rather than refuses is `dependsOn`, so an author // who wrote it keeps working and now has a contract saying so. Everything else // undeclared becomes a parse error. Registered as a structured TODO (ADR-0087 D3) From 2602ccec1045abde0e7eaeb2c6f36574e76983f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 12:43:57 +0000 Subject: [PATCH 03/12] docs(spec): sweep the published eleven-group claim, add the changeset Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- ...ion-bundle-split-settings-platform-only.md | 62 ++++++++++++++++ .../docs/protocol/kernel/i18n-standard.mdx | 26 ++++--- docs/qa/platform-checklist/areas/i18n.json | 4 +- packages/spec/src/system/translation.test.ts | 71 +++++++++++++++++-- skills/objectstack-i18n/SKILL.md | 4 +- 5 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 .changeset/15178-translation-bundle-split-settings-platform-only.md 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..94ec3eaa144 --- /dev/null +++ b/.changeset/15178-translation-bundle-split-settings-platform-only.md @@ -0,0 +1,62 @@ +--- +'@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-②: no + +`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. +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 **overwrote the +platform's own settings copy** for that deployment, under a namespace the +application does not own. After the upgrade the affected Settings screens render +the platform's strings again. If a platform string is wrong, correct it in the +platform bundle (`@objectstack/service-settings`'s `settingsBuiltinTranslations`) +rather than re-adding an app-side override. + +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/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/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/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` — From 029340b03398d04fc9788473ad4eafd3f2f02b41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 12:54:25 +0000 Subject: [PATCH 04/12] chore(spec): regenerate the five artifacts the bundle split moved Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/index.mdx | 10 +- .../docs/references/system/translation.mdx | 169 +++++++++++++++--- ...07-unknown-key-strictness-ledger.counts.md | 2 +- packages/spec/api-surface/system.json | 4 + packages/spec/declaration-map/system.json | 4 + packages/spec/export-origins/system.json | 4 + 7 files changed, 162 insertions(+), 33 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 88d647fc075..2e522c2ce1c 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 a7a6966df63..c0baed4b39d 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 — 1533 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1535 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** | **1533** | 14 protocol modules | +| **Total** | **195** | **1535** | 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/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index f00b4554d0b..64a3aa1c03b 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/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/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)", From 579af31d0f568c7e0c4fab2e1e081572477d2383 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 13:31:52 +0000 Subject: [PATCH 05/12] test(spec): pin the two new platform aliases as ADR-0122 isomorphic Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- packages/spec/src/type-alias-convention.pin.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 276d3fc3460..029c1d42fe7 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -1251,6 +1251,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 Iso877 = Assert, z.infer< typeof M152.PlatformTranslationDataSchema > >>; +export type Iso878 = 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 > >>; From 5283099838d27de003e77c6848fdbf78e11041aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 13:41:22 +0000 Subject: [PATCH 06/12] test(spec): rebalance the ADR-0122 isomorphic pin count 784 -> 786 Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../spec/src/type-alias-convention.pin.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 029c1d42fe7..9fab3738013 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'; // --------------------------------------------------------------------------- -// 784 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 786 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 @@ -1688,7 +1688,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 784 isomorphic pins', () => { + it('still declares all 786 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 @@ -2273,7 +2273,16 @@ describe('ADR-0122 type-alias convention', () => { // carried both halves of the pair — which is also why only ONE of the three // was ever on this list. -1 converted to an `XParsed` pair; the Iso number // stays vacant (ids are claims about pins, not positions). - expect(pins).toHaveLength(784); + // + // 784 -> 786 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. + expect(pins).toHaveLength(786); // 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 From b33ea66cb32237966a0fa65c9b8823a0bd1e76b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 15:19:38 +0000 Subject: [PATCH 07/12] fix(spec): the per-app `settings` record said the app won; the platform did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework on the at-tier FAIL. `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 a per-app `settings` entry was a GAP FILLER — it rendered only where the platform bundle carried no string for that key and locale, and lost every key both defined. Dropping it sends those gaps back to the manifest's own English literal, not "the platform's strings again". Nine sites carried the reversed claim: the changeset body, the D2 conversion's docblock and `summary`, the ADR-0087 entry's `reason` and `acceptanceCriteria`, their two generated copies in `migrations/registry.ts` (regenerated, never hand-edited), the step-18 `rationale`, and the parse-time refusal text `PER_APP_SETTINGS_PLATFORM_ONLY` — plus the two schema docblocks that carried it. Text only: no schema, face, ledger, conversion behaviour or level changes. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- ...ion-bundle-split-settings-platform-only.md | 25 ++++-- packages/spec/src/conversions/registry.ts | 25 ++++-- ...nslation-per-app-settings-platform-only.ts | 62 +++++++++------ packages/spec/src/migrations/registry.ts | 77 +++++++++++-------- packages/spec/src/system/translation.zod.ts | 51 +++++++----- 5 files changed, 152 insertions(+), 88 deletions(-) diff --git a/.changeset/15178-translation-bundle-split-settings-platform-only.md b/.changeset/15178-translation-bundle-split-settings-platform-only.md index 94ec3eaa144..712c2044b10 100644 --- a/.changeset/15178-translation-bundle-split-settings-platform-only.md +++ b/.changeset/15178-translation-bundle-split-settings-platform-only.md @@ -38,12 +38,25 @@ 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 **overwrote the -platform's own settings copy** for that deployment, under a namespace the -application does not own. After the upgrade the affected Settings screens render -the platform's strings again. If a platform string is wrong, correct it in the -platform bundle (`@objectstack/service-settings`'s `settingsBuiltinTranslations`) -rather than re-adding an app-side override. +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. diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index d5401acd477..562b23f7b16 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7125,12 +7125,20 @@ const elementFormRemoved: MetadataConversion = { * 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: it overwrote - * the platform's own settings copy for the deployment. Dropping it restores - * the platform's string, which is the ruled intent — and the semantic entry + * 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 is what happened, because a notice reading "(removed)" does not - * say it. + * 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), @@ -7152,9 +7160,10 @@ const translationPerAppSettingsRemoved: MetadataConversion = { 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 overwrite the ' - + "platform's own settings copy in the one merged served tree; the group stays on the PLATFORM " - + 'bundle, PlatformTranslationData)', + + '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([ diff --git a/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts b/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts index 10a3bfca37f..df97c396c42 100644 --- a/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts +++ b/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts @@ -4,8 +4,9 @@ import type { SemanticMigration } from '../../types.js'; // 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 that the strings being deleted were WORKING — -// and that deleting them changes what the deployment renders. +// `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. export const entry: SemanticMigration = { id: 'translation-per-app-settings-platform-only', surface: 'stack.translations[]..settings — the per-app bundle’s settings group', @@ -21,25 +22,32 @@ export const entry: SemanticMigration = { + 'still declares: `objects`, `apps`, `pages`, `dashboards`, `datasets`, `flows`, ' + '`globalActions`, `metadataForms`, `messages`.', reason: - 'Not losslessly convertible, and NOT because the content was inert — the opposite. 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 at ' - + '`kernel:ready` — 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. An app-authored entry therefore RESOLVED, and what it ' - + 'resolved was an override of the platform’s own settings copy for that deployment, addressed ' - + 'by a namespace the application does not own. Dropping the group restores the platform string, ' - + 'which is the ruled intent — but it is a VISIBLE change to what a Settings screen renders, not ' - + 'a no-op, and a mechanical notice reading "(removed)" does not convey that. 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.', + '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 ' @@ -49,8 +57,12 @@ export const entry: SemanticMigration = { + '`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 overriding platform settings copy ' - + 'from an app bundle: after the upgrade the affected Settings screens render the platform’s own ' - + 'strings again — confirm that is what you want, and if a platform string is wrong, correct it ' - + 'in the platform bundle rather than re-adding the app-side override.', + + '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 484d46762c5..9201d89ee94 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5216,12 +5216,15 @@ const step18: MigrationStep = { + '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 ' - + "— it overwrote the platform's own settings copy for that deployment, under a namespace the " - + 'application does not own. 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 a ' - + "platform string is coming back.", + + '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', @@ -12229,8 +12232,9 @@ const step18: MigrationStep = { }, // 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 that the strings being deleted were WORKING — - // and that deleting them changes what the deployment renders. + // `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', @@ -12246,25 +12250,32 @@ const step18: MigrationStep = { + 'still declares: `objects`, `apps`, `pages`, `dashboards`, `datasets`, `flows`, ' + '`globalActions`, `metadataForms`, `messages`.', reason: - 'Not losslessly convertible, and NOT because the content was inert — the opposite. 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 at ' - + '`kernel:ready` — 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. An app-authored entry therefore RESOLVED, and what it ' - + 'resolved was an override of the platform’s own settings copy for that deployment, addressed ' - + 'by a namespace the application does not own. Dropping the group restores the platform string, ' - + 'which is the ruled intent — but it is a VISIBLE change to what a Settings screen renders, not ' - + 'a no-op, and a mechanical notice reading "(removed)" does not convey that. 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.', + '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 ' @@ -12274,10 +12285,14 @@ const step18: MigrationStep = { + '`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 overriding platform settings copy ' - + 'from an app bundle: after the upgrade the affected Settings screens render the platform’s own ' - + 'strings again — confirm that is what you want, and if a platform string is wrong, correct it ' - + 'in the platform bundle rather than re-adding the app-side override.', + + '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.', }, // The one key this close DECLARES rather than refuses is `dependsOn`, so an author // who wrote it keeps working and now has a contract saying so. Everything else diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 3a27c26f6a5..28e3197929f 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -578,13 +578,17 @@ const PER_APP_SETTINGS_PLATFORM_ONLY = '`settings` is a PLATFORM group, not an application one: it is keyed by ' + '`SettingsManifest.namespace`, and a manifest is platform code — an application cannot ' + 'declare one, so the only namespaces this key could address are the platform\'s own. ' - + 'Authored here it deep-merged into the one served translation tree and silently rewrote ' - + 'the platform\'s own settings copy for the deployment. Delete the group; platform settings ' - + 'copy is translated in the platform bundle ' + + 'Authored here it deep-merged into the one served translation tree, where it rendered only ' + + 'where the platform bundle carried no string for that key and locale: wherever both defined ' + + 'the key the platform\'s own `kernel:ready` contribution arrived later and overwrote it. ' + + 'Delete the group. Platform settings copy is translated in the platform bundle ' + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' - + '`PlatformTranslationData`). For an application\'s own copy use the groups this bundle ' - + "does declare — 'objects', 'apps', 'pages', 'dashboards', 'datasets', 'flows', " - + "'globalActions', 'metadataForms', 'messages'."; + + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + + 'literal, so correct it there rather than filling the gap from an application. For an ' + + "application's own copy use the groups this bundle does declare — 'objects', 'apps', " + + "'pages', 'dashboards', 'datasets', 'flows', 'globalActions', 'metadataForms', 'messages'. " + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply ' + + 'them by hand.'; /** The per-app door's guidance: the shared table plus the platform-only `settings`. */ const APP_TRANSLATION_KEY_GUIDANCE: Record = { @@ -1294,10 +1298,16 @@ const appTranslationDataShape = () => ({ * 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 — - * i.e. it silently rewrote the platform's settings copy for that deployment. - * Platform labels and application labels are separate namespaces (ruling batch - * #132 item 2 letter ②), so this shape is spread into + * {@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. * @@ -1412,14 +1422,19 @@ export type TranslationData = z.input; * namespaces on the platforms this one resembles, and they are separate here * (ruling batch #132 item 2 letter ②, 2026-09-13). * - * What made the merged type actively harmful rather than merely imprecise: - * 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 — it - * overwrote the platform's own settings copy for the deployment, addressed by - * a namespace the application does not own. + * 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', From d5e6b43977a08802f60b29c7024bc1ed86644881 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 17:10:05 +0000 Subject: [PATCH 08/12] fix(spec): the refusal message enumerated nine of the ten per-app groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PER_APP_SETTINGS_PLATFORM_ONLY` told a refused author to use "the groups this bundle does declare" and then left out `settingsCommon` — which is precisely the nearest legitimate neighbour for someone who just had `settings` refused, and the one key that makes the boundary legible: the Settings UI SHELL strings stay authorable, only the per-namespace manifest copy leaves. Read off the BUILT `TranslationDataSchema.shape`, the per-app face declares ten. The message now enumerates all ten, in declaration order, and says what the neighbour is. The changeset's own `Clause-②` line moves `no` -> `yes`: four genuinely-new exported symbols trip the mechanical floor. Documentation consistency only — the gate reads the PR body, and the `minor` bumps are unaffected. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- ...178-translation-bundle-split-settings-platform-only.md | 2 +- packages/spec/src/system/translation.zod.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.changeset/15178-translation-bundle-split-settings-platform-only.md b/.changeset/15178-translation-bundle-split-settings-platform-only.md index 712c2044b10..93f1d087e2f 100644 --- a/.changeset/15178-translation-bundle-split-settings-platform-only.md +++ b/.changeset/15178-translation-bundle-split-settings-platform-only.md @@ -5,7 +5,7 @@ **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-②: no +Clause-②: yes `TranslationDataSchema` served two different bundles at once — the per-app one an application authors (`stack.translations`, `defineTranslationBundle`) and the diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 28e3197929f..fb7ad26f0a9 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -585,8 +585,12 @@ const PER_APP_SETTINGS_PLATFORM_ONLY = + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + 'literal, so correct it there rather than filling the gap from an application. For an ' - + "application's own copy use the groups this bundle does declare — 'objects', 'apps', " - + "'pages', 'dashboards', 'datasets', 'flows', 'globalActions', 'metadataForms', 'messages'. " + + 'application\'s own copy use the ten groups this bundle does declare, in the order it ' + + "declares them — 'objects', 'apps', 'messages', 'globalActions', 'dashboards', 'datasets', " + + "'pages', 'flows', 'metadataForms', 'settingsCommon'. Note the last one: 'settingsCommon' 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 refused here — only the ' + + "per-namespace manifest copy under 'settings' is. " + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply ' + 'them by hand.'; From b1e7984040a9e7b79bafe28abd4417dcb315d20c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 18:33:46 +0000 Subject: [PATCH 09/12] docs(ui): the translations guide still taught the refused `settings` key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/ui/translations.mdx` is a published app-author guide, and its "What you can translate" table told an application to put its settings copy in `settings` — a group this change makes platform-only and refuses by name in a file-authored bundle. Shipping the refusal beside a page that teaches the refused key is the state this PR's own body calls unacceptable. The row now names `globalActions` and `messages` only, and a new row points at `settingsCommon.sourceLabels` — the Settings UI shell strings that DO stay on the per-app face — while saying where the per-namespace manifest copy is translated instead. Two neighbours in the same sections said the opposite and are corrected with it. "Authoring in the product" claimed a `translation` item carries the **same** groups as a file bundle; it carries one more. And "only the groups on this page are accepted ... in a runtime item AND in a file-authored bundle" would have become false the moment `settings` left the page, because the registered item still declares it. Both now state the one asymmetry explicitly. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- content/docs/ui/translations.mdx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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. From 8dcd6a42ae323931b02e12208bf229da921b9eb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 18:34:04 +0000 Subject: [PATCH 10/12] fix(spec): derive the migration TODO's group list from the schema, not a literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The semantic migration entry for `translation-per-app-settings-platform-only` enumerated nine of the ten groups the per-app face declares and omitted `settingsCommon` — the nearest legitimate neighbour for an author who has just had `settings` refused. `os migrate meta --from 17` prints this string verbatim to the operator (`packages/cli/src/commands/migrate/meta.ts` renders `surface → replacement`), so the omission reached a user-facing surface. Correcting the literal would leave the construct that produced it. A hand-maintained copy of a schema's key set already drifted here once, through a full review of the surrounding change, so the copy is deleted rather than pinned: `replacement` is now a getter that reads `Object.keys(TranslationDataSchema.shape)`. There is one spelling of the set, and a group added to the per-app face reaches this message the day it is declared, with nothing to remember. Ordered remedy — delete the construct that permits the error before reaching for a check that only reddens it. `Object.keys` on a zod object shape returns the declaration order of the literal it was built from, which is the order the sentence promises; the separator and the shape of the sentence are unchanged, so the rendered paragraph reads as it did. A getter rather than an eager template because importing the registry must not force the lazy translation schema at module load. The registry is generated by concatenating entry literals, and the generator treats a file's imports as scaffolding — so `registry.ts` carries its own hand-written value import outside the generated regions, with a comment saying why. Verified: it survives `gen:migration-registry`, and `system/translation.zod.ts`'s own 26-module closure reaches nothing under `migrations/`, so the edge adds no cycle. The changeset gains one sentence naming `settingsCommon` as unaffected. The declared semver level is untouched — both packages stay `minor`; two prose corrections and one derivation move no published signature. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- ...ion-bundle-split-settings-platform-only.md | 3 ++ ...nslation-per-app-settings-platform-only.ts | 37 +++++++++++----- packages/spec/src/migrations/registry.ts | 43 ++++++++++++++----- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/.changeset/15178-translation-bundle-split-settings-platform-only.md b/.changeset/15178-translation-bundle-split-settings-platform-only.md index 93f1d087e2f..261d92a1429 100644 --- a/.changeset/15178-translation-bundle-split-settings-platform-only.md +++ b/.changeset/15178-translation-bundle-split-settings-platform-only.md @@ -28,6 +28,9 @@ included. 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. diff --git a/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts b/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts index df97c396c42..2f19d4948ea 100644 --- a/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts +++ b/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { TranslationDataSchema } from '../../../system/translation.zod.js'; import type { SemanticMigration } from '../../types.js'; // The judgment half of `translation-per-app-settings-removed`. The D2 @@ -10,17 +11,31 @@ import type { SemanticMigration } from '../../types.js'; export const entry: SemanticMigration = { id: 'translation-per-app-settings-platform-only', surface: 'stack.translations[]..settings — the per-app bundle’s settings group', - replacement: - '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 the per-app bundle ' - + 'still declares: `objects`, `apps`, `pages`, `dashboards`, `datasets`, `flows`, ' - + '`globalActions`, `metadataForms`, `messages`.', + // 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: ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9201d89ee94..8e31756bc23 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'; /** @@ -12238,17 +12245,31 @@ const step18: MigrationStep = { { id: 'translation-per-app-settings-platform-only', surface: 'stack.translations[]..settings — the per-app bundle’s settings group', - replacement: - '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 the per-app bundle ' - + 'still declares: `objects`, `apps`, `pages`, `dashboards`, `datasets`, `flows`, ' - + '`globalActions`, `metadataForms`, `messages`.', + // 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: ' From 34b6a25b2cf2ccb02cdddf915a03f75825043c09 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 06:20:01 +0000 Subject: [PATCH 11/12] chore(spec): regenerate the two os-regen artifacts on the merged tree Discharges the deferral the merge commit recorded. Both files are generated, never hand-merged: `gen:strictness-ledger` for the counts ledger, and `gen:schema` then `gen:docs` for the references index (it renders from the gitignored packages/spec/json-schema/ tree and refuses without the first). Each regenerated file now equals main's version plus this branch's own contribution and nothing else: index.mdx differs from origin/main only by `PlatformTranslationBundle` / `PlatformTranslationData` (System 273 -> 275, total 1534 -> 1536), and the counts ledger only by `system/` 351 -> 352. `check:generated` reports all 15 artifacts up to date. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- content/docs/references/index.mdx | 16 ++++++++-------- ...26-07-unknown-key-strictness-ledger.counts.md | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index c0baed4b39d..0e121bedba9 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 — 1536 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/. */} @@ -20,12 +20,12 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 31 | 441 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 31 | 444 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | -| [Kernel Protocol](/docs/references/kernel) | 30 | 159 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | +| [Kernel Protocol](/docs/references/kernel) | 30 | 157 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | | [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [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** | **1536** | 14 protocol modules | --- @@ -62,13 +62,13 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 441 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 444 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. | File | Schemas | | :--- | :--- | -| [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `GetAnalyticsMetaRequest` | +| [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `DatasetCompareTo`, `DatasetSelection`, `DatasetTotals`, `GetAnalyticsMetaRequest` | | [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` | | [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` | | [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowSummary`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListFlowsRequest`, `ListFlowsResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | @@ -197,7 +197,7 @@ The single connector protocol (ADR-0097) — catalog descriptors and provider-bo ## Kernel Protocol -**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **30 pages, 159 schemas** +**Source:** `packages/spec/src/kernel/` · **Import:** `@objectstack/spec/kernel` · **30 pages, 157 schemas** Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. @@ -227,7 +227,7 @@ Plugin lifecycle and manifests, capabilities and security, metadata loading, ser | [`plugin-loading.zod.ts`](/docs/references/kernel/plugin-loading) | `PluginLoadingEvent`, `PluginLoadingState` | | [`plugin-registry.zod.ts`](/docs/references/kernel/plugin-registry) | `PluginInstallConfig`, `PluginQualityMetrics`, `PluginRegistryEntry`, `PluginSearchFilters`, `PluginStatistics`, `PluginVendor` | | [`plugin-security.zod.ts`](/docs/references/kernel/plugin-security) | `DependencyGraph`, `DependencyGraphNode`, `PackageDependencyConflict`, `PackageDependencyResolutionResult`, `PluginProvenance`, `PluginTrustScore`, `ResolvedPackageDependency`, `SBOM`, `SBOMEntry`, `SecurityPolicy`, `SecurityScanResult`, `SecurityVulnerability`, `VulnerabilitySeverity` | -| [`plugin-security-advanced.zod.ts`](/docs/references/kernel/plugin-security-advanced) | `KernelSecurityPolicy`, `KernelSecurityScanResult`, `KernelSecurityVulnerability`, `PermissionAction`, `PermissionScope`, `PluginPermission`, `PluginPermissionSet`, `PluginSecurityManifest`, `PluginTrustLevel`, `ResourceType`, `RuntimeConfig`, `SandboxConfig` | +| [`plugin-security-advanced.zod.ts`](/docs/references/kernel/plugin-security-advanced) | `KernelSecurityPolicy`, `PermissionAction`, `PermissionScope`, `PluginPermission`, `PluginPermissionSet`, `PluginSecurityManifest`, `PluginTrustLevel`, `ResourceType`, `RuntimeConfig`, `SandboxConfig` | | [`plugin-structure.zod.ts`](/docs/references/kernel/plugin-structure) | `OpsDomainModule`, `OpsFilePath`, `OpsPluginStructure` | | [`plugin-validator.zod.ts`](/docs/references/kernel/plugin-validator) | `PluginMetadata`, `ValidationError`, `ValidationResult`, `ValidationWarning` | | [`plugin-versioning.zod.ts`](/docs/references/kernel/plugin-versioning) | `BreakingChange`, `CompatibilityLevel`, `CompatibilityMatrixEntry`, `DependencyConflict`, `DeprecationNotice`, `MultiVersionSupport`, `PluginCompatibilityMatrix`, `PluginDependencyResolutionResult`, `PluginVersionMetadata`, `SemanticVersion`, `VersionConstraint` | 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 64a3aa1c03b..1e815436dec 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,10 +257,10 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 78 | -| `api/` | 451 | +| `api/` | 454 | | `identity/` | 32 | | `integration/` | 8 | -| `kernel/` | 257 | +| `kernel/` | 247 | | `marketplace/` | 29 | | `qa/` | 6 | | `shared/` | 20 | From 9479042a3f31ac54f6072f61300bb7705dc7e669 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:59:10 +0000 Subject: [PATCH 12/12] chore(spec): regenerate the os-regen docs artifacts on the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discharges the deferral the merge commit took. The os-regen driver merged both `content/docs/references/` artifacts with exit 0 while keeping one side, so the committed merge carried a schema count (1536 / automation 74) that was neither side's. Regenerated from the merged sources with `gen:schema && gen:docs`; the result now carries both sides: - `index.mdx` — 1537 schemas (main's `FlowFunctionLoweredDeclaration` and this branch's `PlatformTranslationBundle` / `PlatformTranslationData`). - `api/protocol.mdx` — main's `enableOnInstall` description and this branch's `TranslationData.settings` row move. `pnpm --filter @objectstack/spec check:generated` reports all 15 artifacts up to date. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/index.mdx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 2e522c2ce1c..c89a11f3f05 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1910,7 +1910,7 @@ Install package request | :--- | :--- | :--- | :--- | | **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install | | **settings** | `Record` | optional | User-provided settings at install time | -| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive does not read it | +| **enableOnInstall** | `boolean` | optional | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive honours it on the registry row: `true` enables, `false` disables, and ABSENT keeps the row's current lifecycle state (a fresh install lands enabled) | | **platformVersion** | `string` | optional | Current platform version for compatibility verification | ### Nested Shape: `InstallPackageRequest.manifest` diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 0e121bedba9..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 — 1536 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/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 31 | 444 | REST contracts, endpoints, routing, realtime, batch, discovery. | -| [Automation Protocol](/docs/references/automation) | 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | +| [Automation Protocol](/docs/references/automation) | 14 | 75 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [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** | **1536** | 14 protocol modules | +| **Total** | **195** | **1537** | 14 protocol modules | --- @@ -104,7 +104,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. ## Automation Protocol -**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 74 schemas** +**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 75 schemas** Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. @@ -116,7 +116,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` | | [`execution.zod.ts`](/docs/references/automation/execution) | `Checkpoint`, `ConcurrencyPolicy`, `ExecutionError`, `ExecutionErrorSeverity`, `ExecutionLog`, `ExecutionStatus`, `ExecutionStepLog`, `ExecutionStepMetrics`, `ExecutionStepSkipReason`, `FlowRunGateSummary`, `FlowRunNodeSummary`, `FlowRunSummary`, `ScheduleState` | | [`flow.zod.ts`](/docs/references/automation/flow) | `Flow`, `FlowEdge`, `FlowNode`, `FlowNodeAction`, `FlowVariable`, `FlowVersionHistory` | -| [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect` | +| [`flow-function.zod.ts`](/docs/references/automation/flow-function) | `FlowFunctionEffect`, `FlowFunctionLoweredDeclaration` | | [`io-node-config.zod.ts`](/docs/references/automation/io-node-config) | `HttpConfig`, `NotifyConfig` | | [`node-executor.zod.ts`](/docs/references/automation/node-executor) | `ActionCategory`, `ActionDescriptor`, `ActionParadigm`, `NodeExecutorDescriptor`, `WaitEventType`, `WaitExecutorConfig`, `WaitResumePayload`, `WaitTimeoutBehavior` | | [`schedule-organization.zod.ts`](/docs/references/automation/schedule-organization) | `ScheduleOrganization` |