From 7025fd4b29ecdce0cc97cc62a399533435639802 Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 2 Sep 2026 00:55:01 +0000 Subject: [PATCH 1/6] feat(spec): translate a list view's bulkActionDefs from the bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `bulkActionDefs` entry is part of the VIEW document, not an action document, so it never reaches `translateAction` and no bundle group addressed it. Measured against a fully translated app the selection bar read `已选择 1 项 · Complete · Skip · 清除` — two English words between two Chinese ones, which reads as a styling quirk rather than a missing translation. Not a drifted key: no key. Adds `objects.._views..bulkActions.` carrying `label` / `confirmText` / `confirmLabel` and per-param `label` / `help` / `placeholder`, resolved in `translateView` against `config.bulkActionDefs` — the one address a served def has (`ViewItemSchema` and `expandViewContainer` both nest the whole ListView under `config`). The def's `label` stays `z.string()` on the authoring side: the bar renders it as a React child, so an inline locale map would be a blank cell rather than a parse error (`ui/bulk-action.zod.ts` module header). Overlaying at the metadata boundary keeps the wire value a plain string and changes only its language. Key face measured against `BulkActionDefSchema`, not mirrored from the report. Two exclusions carry `guidance`: `successMessage` (a def declares none) and per-param `options` (`options[].value` is unconstrained, so a value-keyed map cannot address `true` and `"true"` apart — the same measured reason `FLOW_SCREEN_FIELD_NO_OPTIONS` gives). `help`, not `helpText`: the face follows the authored key and aliases the neighbouring action-param spelling onto it. Part of #14253 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- .../spec/src/system/i18n-resolver.test.ts | 212 ++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 182 ++++++++++++++- packages/spec/src/system/translation.zod.ts | 127 ++++++++++- 3 files changed, 517 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 720f23cdbb..c5f8d073b2 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -2750,3 +2750,215 @@ describe('resolveFlowScreenTitle (#11287)', () => { .toBe('转化详情'); }); }); + +/** + * Bulk-action defs on a list view (#14253, surface 1). + * + * A `bulkActionDefs` entry is part of the VIEW document, so it never reaches + * `translateAction` and no bundle group addressed it. Measured against a fully + * translated app, the selection bar read + * `已选择 1 项 · Complete · Skip · 清除` — two English words between two Chinese + * ones, which reads as a styling quirk rather than a missing translation. + * + * The fixture is DERIVED from `expandViewContainer`, not transcribed: the defs + * have to sit exactly where the serving path puts them (`config.bulkActionDefs`) + * or the pin describes a shape the runtime never produces — the #4854 lesson, + * one level in. + */ +describe('translateView — bulkActionDefs (#14253)', () => { + const container = { + listViews: { + open_duties: { + label: 'Open Duties', + type: 'grid' as const, + data: { provider: 'object' as const, object: 'duly_duty' }, + columns: [{ field: 'name' }], + bulkActionDefs: [ + { + name: 'complete', + label: 'Complete', + operation: 'update' as const, + patch: { status: 'done' }, + confirmText: 'Mark the selected duties complete?', + confirmLabel: 'Complete them', + params: [ + { name: 'note', type: 'text' as const, label: 'Completion note', help: 'Shown on the timeline', placeholder: 'Optional' }, + // No `params.reason` entry in the bundle — the negative control. + { name: 'reason', type: 'text' as const, label: 'Reason' }, + ], + }, + { + name: 'skip', + label: 'Skip', + operation: 'update' as const, + patch: { status: 'skipped' }, + }, + // No bundle entry at all — must come back byte-identical. + { + name: 'archive', + label: 'Archive', + operation: 'delete' as const, + }, + ], + }, + }, + }; + + const served = expandViewContainer('duly_duty', container); + const view = () => served.find((v) => v.name === 'duly_duty.open_duties')! as any; + + const bundle: TranslationBundle = { + 'zh-CN': { + objects: { + duly_duty: { + _views: { + open_duties: { + label: '待办任务', + bulkActions: { + complete: { + label: '完成', + confirmText: '确定要将所选任务标记为完成吗?', + confirmLabel: '确认完成', + params: { + note: { label: '完成备注', help: '会显示在动态中', placeholder: '选填' }, + }, + }, + skip: { label: '跳过' }, + }, + }, + }, + }, + }, + }, + }; + + it('composes the identity this test is pinned to — defs live under `config`', () => { + const item = view(); + // The authored defs are NOT at the document's top level; `ViewItemSchema` + // and `expandViewContainer` both nest the whole ListView under `config`. + expect(item.bulkActionDefs).toBeUndefined(); + expect(Array.isArray(item.config.bulkActionDefs)).toBe(true); + expect(item.config.bulkActionDefs.map((d: any) => d.name)).toEqual(['complete', 'skip', 'archive']); + }); + + it('translates the def label, confirm prompt and confirm button', () => { + const out = translateMetadataDocument('view', view(), bundle, { locale: 'zh-CN' }) as any; + const complete = out.config.bulkActionDefs.find((d: any) => d.name === 'complete'); + expect(complete.label).toBe('完成'); + expect(complete.confirmText).toBe('确定要将所选任务标记为完成吗?'); + expect(complete.confirmLabel).toBe('确认完成'); + // The def's non-copy keys ride through untouched. + expect(complete.operation).toBe('update'); + expect(complete.patch).toEqual({ status: 'done' }); + }); + + it('translates a param label / help / placeholder, keyed by param name', () => { + const out = translateMetadataDocument('view', view(), bundle, { locale: 'zh-CN' }) as any; + const complete = out.config.bulkActionDefs.find((d: any) => d.name === 'complete'); + const note = complete.params.find((p: any) => p.name === 'note'); + expect(note.label).toBe('完成备注'); + // A bulk param spells its hint `help`, not `helpText` — the face follows + // `BulkActionParamSchema`, not the neighbouring action-param surface. + expect(note.help).toBe('会显示在动态中'); + expect(note.placeholder).toBe('选填'); + expect(note.type).toBe('text'); + }); + + it('leaves an untranslated def, and an untranslated param, exactly as authored', () => { + const out = translateMetadataDocument('view', view(), bundle, { locale: 'zh-CN' }) as any; + const archive = out.config.bulkActionDefs.find((d: any) => d.name === 'archive'); + expect(archive.label).toBe('Archive'); + const reason = out.config.bulkActionDefs + .find((d: any) => d.name === 'complete').params.find((p: any) => p.name === 'reason'); + expect(reason.label).toBe('Reason'); + expect(reason.help).toBeUndefined(); + }); + + it('translates the view label and the defs in one pass', () => { + const out = translateMetadataDocument('view', view(), bundle, { locale: 'zh-CN' }) as any; + expect(out.label).toBe('待办任务'); + expect(out.config.bulkActionDefs.find((d: any) => d.name === 'skip').label).toBe('跳过'); + }); + + it('does not mutate the input document', () => { + const item = view(); + const before = JSON.parse(JSON.stringify(item.config.bulkActionDefs)); + translateMetadataDocument('view', item, bundle, { locale: 'zh-CN' }); + expect(item.config.bulkActionDefs).toEqual(before); + }); + + it('returns the SAME `config` reference when no def gained a translation', () => { + const item = view(); + // A locale the bundle does not carry, with no `en` entry to fall back to. + const out = translateMetadataDocument('view', item, { 'ja-JP': {} }, { locale: 'ja-JP' }) as any; + expect(out.config).toBe(item.config); + }); + + it('applies the BCP-47 ladder and the fallback chain the rest of the surface uses', () => { + const out = translateMetadataDocument('view', view(), bundle, { locale: 'zh' }) as any; + expect(out.config.bulkActionDefs.find((d: any) => d.name === 'complete').label).toBe('完成'); + const viaChain = translateMetadataDocument('view', view(), bundle, { + locale: 'fr-FR', + fallbackChain: ['zh-CN'], + }) as any; + expect(viaChain.config.bulkActionDefs.find((d: any) => d.name === 'skip').label).toBe('跳过'); + }); +}); + +describe('ObjectTranslationDataSchema — _views..bulkActions (#14253)', () => { + it('accepts the measured key face', () => { + const data = ObjectTranslationDataSchema.parse({ + _views: { + open_duties: { + bulkActions: { + complete: { + label: '完成', + confirmText: '确定?', + confirmLabel: '确认', + params: { note: { label: '备注', help: '提示', placeholder: '选填' } }, + }, + }, + }, + }, + }); + expect(data._views?.open_duties.bulkActions?.complete.label).toBe('完成'); + expect(data._views?.open_duties.bulkActions?.complete.params?.note.help).toBe('提示'); + }); + + it('renames the authoring spelling `bulkActionDefs` onto the translation group', () => { + expect(() => ObjectTranslationDataSchema.parse({ + _views: { open_duties: { bulkActionDefs: { complete: { label: '完成' } } } }, + })).toThrow(/bulkActionDefs[\s\S]*bulkActions/); + }); + + it('renames `helpText` — the ACTION-param spelling — onto a bulk param\'s `help`', () => { + expect(() => ObjectTranslationDataSchema.parse({ + _views: { open_duties: { bulkActions: { complete: { params: { note: { helpText: '提示' } } } } } }, + })).toThrow(/helpText[\s\S]*help/); + }); + + it('refuses per-param `options` with the reason, not a rename', () => { + let message = ''; + try { + ObjectTranslationDataSchema.parse({ + _views: { open_duties: { bulkActions: { complete: { params: { note: { options: { a: 'A' } } } } } } }, + }); + } catch (err) { + message = String(err); + } + expect(message).toMatch(/unconstrained/); + expect(message).toMatch(/objects\.\.fields\.\.options/); + }); + + it('refuses `successMessage` on a def — it declares no success copy', () => { + expect(() => ObjectTranslationDataSchema.parse({ + _views: { open_duties: { bulkActions: { complete: { successMessage: '完成了' } } } }, + })).toThrow(/declares no success copy/); + }); + + it('points a `rowActions` author at the action document instead', () => { + expect(() => ObjectTranslationDataSchema.parse({ + _views: { open_duties: { rowActions: { edit: { label: '编辑' } } } }, + })).toThrow(/_actions\.\.label/); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 0f8fc7558f..81209f0f7b 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -88,8 +88,39 @@ export interface ViewLike { * `data.object` for views that retarget another object. Read only as a last * resort — a default `form` config carries no `data` at all, which is why * top-level `object` is the load-bearing field. + * + * It is also where a list view's `bulkActionDefs[]` live — `ViewItemSchema` + * puts the whole `ListViewSchema` under `config`, and `expandViewContainer` + * does the same for a container-authored view, so `config.bulkActionDefs` is + * the ONE address a served def has. See {@link translateView}. */ - config?: { data?: { object?: string } }; + config?: { data?: { object?: string }; bulkActionDefs?: unknown; [key: string]: unknown }; +} + +/** + * Minimal bulk-action-def shape consumed by {@link translateView} — + * `BulkActionDefSchema` (`ui/bulk-action.zod.ts`) narrowed to the copy this + * resolver overlays. + */ +export interface BulkActionDefLike { + /** `BulkActionDefSchema.name` — the `bulkActions` key. */ + name?: string; + label?: string; + confirmText?: string; + confirmLabel?: string; + params?: Array; + [key: string]: unknown; +} + +/** Minimal bulk-action param shape consumed by {@link translateView}. */ +export interface BulkActionParamLike { + /** `BulkActionParamSchema.name` — the `params` key. */ + name?: string; + label?: string; + /** A bulk param spells its hint `help`; an ACTION param spells it `helpText`. */ + help?: string; + placeholder?: string; + [key: string]: unknown; } /** Minimal action shape consumed by the action resolvers. */ @@ -517,12 +548,138 @@ export function resolveActionSuccess( ); } +/** + * The `_views..bulkActions` node for one def, in one locale's data. + */ +function lookupBulkActionNode( + data: TranslationData | undefined, + objectName: string, + viewKey: string, + defName: string, +): NonNullable[string]['_views']>[string]['bulkActions']>[string] | undefined { + return data?.objects?.[objectName]?._views?.[viewKey]?.bulkActions?.[defName]; +} + +/** + * One string off a bulk-action def's translation node, across the locale chain. + * `undefined` when no locale carries it — the caller then leaves the authored + * value in place rather than overwriting it with a fallback. + */ +function lookupBulkActionText( + bundle: TranslationBundle | undefined, + objectName: string, + viewKey: string, + defName: string, + pick: (node: NonNullable>) => unknown, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const node = lookupBulkActionNode(pickData(bundle, code), objectName, viewKey, defName); + if (!node) continue; + const value = pick(node); + if (typeof value === 'string' && value.length > 0) return value; + } + return undefined; +} + +/** + * Overlay the bundle's `bulkActions` copy onto a list view's authored + * `bulkActionDefs[]`, returning the SAME array reference when nothing matched. + * + * Reference identity is load-bearing rather than tidy: `translateView` uses it + * to decide whether to rebuild `config` at all, so a view with no bulk + * translations comes back with its config object untouched. + */ +function translateBulkActionDefs( + defs: unknown, + bundle: TranslationBundle | undefined, + objectName: string, + viewKey: string, + opts?: ResolveOptions, +): unknown { + if (!Array.isArray(defs) || !bundle) return defs; + let changed = false; + const next = defs.map((raw) => { + const def = raw as BulkActionDefLike; + if (!def || typeof def !== 'object' || typeof def.name !== 'string' || def.name.length === 0) { + return raw; + } + const defName = def.name; + const text = (pick: (node: NonNullable>) => unknown) => + lookupBulkActionText(bundle, objectName, viewKey, defName, pick, opts); + + const label = text((n) => n.label); + const confirmText = text((n) => n.confirmText); + const confirmLabel = text((n) => n.confirmLabel); + + let params = def.params; + if (Array.isArray(def.params)) { + let paramsChanged = false; + const nextParams = def.params.map((param) => { + if (!param || typeof param !== 'object' || typeof param.name !== 'string' || param.name.length === 0) { + return param; + } + const paramName = param.name; + const pLabel = text((n) => n.params?.[paramName]?.label); + const pHelp = text((n) => n.params?.[paramName]?.help); + const pPlaceholder = text((n) => n.params?.[paramName]?.placeholder); + if (pLabel === undefined && pHelp === undefined && pPlaceholder === undefined) return param; + paramsChanged = true; + return { + ...param, + ...(pLabel !== undefined ? { label: pLabel } : {}), + ...(pHelp !== undefined ? { help: pHelp } : {}), + ...(pPlaceholder !== undefined ? { placeholder: pPlaceholder } : {}), + }; + }); + if (paramsChanged) params = nextParams; + } + + if ( + label === undefined && confirmText === undefined && confirmLabel === undefined + && params === def.params + ) { + return raw; + } + changed = true; + return { + ...def, + ...(label !== undefined ? { label } : {}), + ...(confirmText !== undefined ? { confirmText } : {}), + ...(confirmLabel !== undefined ? { confirmLabel } : {}), + ...(params !== def.params ? { params } : {}), + }; + }); + return changed ? next : defs; +} + /** * Apply the active locale to a view metadata document by overwriting `label` - * and `description` with translated values when available. The original + * and `description` with translated values when available, and overlaying the + * selection-bar copy of the view's `config.bulkActionDefs[]`. The original * document is not mutated; a shallow copy is returned. Useful for translating * metadata at the API boundary so any client (Studio, app-shell, plain HTTP) * receives already-localized labels. + * + * ## Why the bulk-action defs are translated HERE + * + * A `bulkActionDefs` entry is part of the VIEW document — it is not an action + * document, so it never reaches {@link translateAction} and nothing else in the + * pipeline sees it. Before this, a fully translated app still drew + * `已选择 1 项 · Complete · Skip · 清除` in the selection bar: the def's `label` + * is a plain `z.string()` that reaches the grid verbatim + * (`ui/bulk-action.zod.ts`'s module header explains why it must stay one), so + * the source-locale string was the only string there was. + * + * Overlaying at this boundary keeps the wire value a plain string — the + * renderer's contract is unchanged; only its content is now the caller's + * language. + * + * `config` is the one address a served def has: both `ViewItemSchema` and + * `expandViewContainer` put the whole `ListViewSchema` under `config`. The + * object is rebuilt only when a def actually gained a translation, so a view + * with none comes back with the very same `config` reference. */ export function translateView( view: T, @@ -531,7 +688,26 @@ export function translateView( ): T { const label = resolveViewLabel(bundle, view, opts); const description = resolveViewDescription(bundle, view, opts); - return { ...view, label, ...(description !== undefined ? { description } : {}) }; + + let config = view.config; + const objectName = viewObjectName(view); + if (config && typeof config === 'object' && bundle && objectName) { + const defs = translateBulkActionDefs( + config.bulkActionDefs, + bundle, + objectName, + viewTranslationKey(view, objectName), + opts, + ); + if (defs !== config.bulkActionDefs) config = { ...config, bulkActionDefs: defs }; + } + + return { + ...view, + label, + ...(description !== undefined ? { description } : {}), + ...(config !== view.config ? { config } : {}), + }; } /** diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index bf32244d86..1e17dd58ca 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -27,6 +27,24 @@ const TRANSLATION_HISTORY = + 'loaded, and whatever it was meant to translate rendered in the source language with no ' + 'diagnostic, indistinguishable from a translation nobody had written yet.'; +/** + * The measured exclusion on `objects.._views..bulkActions..params.

`. + * + * Identical in kind to `FLOW_SCREEN_FIELD_NO_OPTIONS` below, and for the same + * measured reason rather than by symmetry: `BulkActionParamSchema.options[].value` + * is `z.union([z.string(), z.number(), z.boolean()])`, so an option map keyed by + * value — the shape `objects..fields..options` uses — cannot + * address `true` and `"true"` apart. There is no right key to send the author + * to, which is why this is `guidance` and not an alias. + */ +const BULK_PARAM_NO_OPTIONS = + 'select-option labels are not translatable on a bulk-action param: ' + + '`BulkActionParamSchema.options[].value` is unconstrained (numbers and booleans are legal), so an ' + + 'option map keyed by value — the shape `objects..fields..options` uses — cannot ' + + 'address them unambiguously. Point the param at a declared field (`type` + the object\'s own ' + + 'options, which ARE translatable under `objects..fields..options`), or accept the ' + + 'authored option labels.'; + // ──────────────────────────────────────────────────────────────────────────── // Object-level Translation (per-object file) // ──────────────────────────────────────────────────────────────────────────── @@ -184,11 +202,26 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ * objects.._views..description * objects.._views..emptyState.title * objects.._views..emptyState.message + * objects.._views..bulkActions..* */ _views: z.record(z.string(), strictObject({ surface: 'this view translation', history: TRANSLATION_HISTORY, - aliases: { name: 'label', title: 'label', empty: 'emptyState', emptyMessage: 'emptyState' }, + aliases: { + name: 'label', title: 'label', empty: 'emptyState', emptyMessage: 'emptyState', + // The authoring key is `bulkActionDefs`; the translation group drops the + // `Defs` suffix the way `_actions` drops nothing and `_tabs` drops + // `tabs[]`'s bracket — one address per surface, and the authoring + // spelling is the one an author reaches for first. + bulkActionDefs: 'bulkActions', bulkActionDef: 'bulkActions', bulk: 'bulkActions', + }, + guidance: { + rowActions: + '`rowActions` names actions the OBJECT declares — a row-action label is translated where the ' + + "action lives: 'objects.._actions..label' (or 'globalActions..label' " + + 'for an object-less action). Only a `bulkActionDefs` entry, which is authored inside the view ' + + "and is not an action document, is addressed here — under 'bulkActions'.", + }, }, { label: z.string().optional().describe('Translated view label'), description: z.string().optional().describe('Translated view description'), @@ -200,6 +233,98 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ title: z.string().optional().describe('Translated empty-state title'), message: z.string().optional().describe('Translated empty-state message'), }).optional().describe('Translated empty-state copy shown when the view has no rows'), + + /** + * Selection-bar copy for the view's `bulkActionDefs[]`, keyed by the def's + * `name` (`BulkActionDefSchema.name`, snake_case). + * + * **The hole this closes.** A bulk-action def is part of the VIEW document, + * not an action document, so it never reaches `translateAction` and no + * group addressed it: the selection bar rendered + * `已选择 1 项 · Complete · Skip · 清除` — two English words between two + * Chinese ones, on a fully translated screen. Not a drifted key; no key. + * The bar renders `def.label` verbatim (`resolveBulkActions` documents that + * an authored def is "left as-authored"), so the source-locale string was + * the only string there was. + * + * **Why this and not the `bulkActions: ['']` promotion.** Naming a + * declared action instead DOES localize — that path runs through + * `translateAction` — but it is not the same capability: it dispatches the + * action once per selected record, where a def is one data-plane + * `updateMany`/`deleteMany` (or one `execution: 'aggregate'` call). Telling + * an author to trade N elevated dispatches for a translated label is a + * workaround, not a translation route. + * + * **Why a bundle key and not `I18nLabelSchema` on the def.** The def's + * `label` stays `z.string()` deliberately (`ui/bulk-action.zod.ts` module + * header): the def reaches the grid verbatim and the bar renders it as a + * React child, so an inline `{ en, zh-CN }` map would render as a blank + * cell rather than a parse error. Overlaying from the bundle in + * `translateView` keeps the wire value a plain string — the renderer's + * contract is untouched, and it is the same seat that already localizes the + * view's own `label`/`description`. + * + * **The key face is measured against `BulkActionDefSchema`, not mirrored + * from the report.** Every key below is copy the def actually declares: + * `label`, `confirmText`, `confirmLabel`, and per-param `label` / `help` / + * `placeholder`. Two deliberate exclusions carry `guidance` instead: + * `successMessage` (a def declares none — the run reports a per-record + * outcome summary the console words itself) and per-param `options`. + * + * ⚠️ `help`, not `helpText`. An ACTION param spells its hint `helpText` + * and a bulk param spells it `help` (`BulkActionParamSchema.help`) — the + * known divergence `ui/bulk-action.zod.ts` names. This face follows the + * authored key, and the neighbouring spelling is an alias rather than a + * second declared key, so one string keeps one address. + */ + bulkActions: z.record(z.string(), strictObject({ + surface: 'this bulk action translation', + history: TRANSLATION_HISTORY, + // Mirrors `BulkActionDefSchema`'s own alias table so an author's habits + // land on the same canonical key on both the authoring and the + // translation side. + aliases: { + name: 'label', title: 'label', + confirm: 'confirmText', confirmation: 'confirmText', confirmMessage: 'confirmText', + confirmButton: 'confirmLabel', confirmButtonLabel: 'confirmLabel', runLabel: 'confirmLabel', + parameters: 'params', args: 'params', inputs: 'params', + }, + guidance: { + successMessage: + '`successMessage` is not part of the bulk-action translation surface — a `bulkActionDefs` ' + + 'entry declares no success copy (`BulkActionDefSchema` has no such key). The run reports a ' + + 'per-record outcome summary the console words from its own catalog. Translate the button ' + + '(`label`), the confirmation prompt (`confirmText`) and the confirm button (`confirmLabel`).', + description: + '`description` is not part of the bulk-action translation surface — a def declares no ' + + 'description. The explanatory sentence above the affected-record summary is `confirmText`.', + icon: + '`icon` is a Lucide icon name, not display copy — it is the same string in every locale. ' + + 'Omit it here.', + }, + }, { + label: z.string().optional().describe('Translated selection-bar button label (overlays `bulkActionDefs[].label`)'), + confirmText: z.string().optional().describe('Translated confirmation prompt shown above the affected-record summary'), + confirmLabel: z.string().optional().describe('Translated Confirm button label'), + params: z.record(z.string(), strictObject({ + surface: 'this bulk action parameter translation', + history: TRANSLATION_HISTORY, + // `helpText` is the correct spelling on an ACTION param and `help` on a + // FIELD translation and a settings key; a BULK param declares `help`. + // Neighbouring-surface borrowing, which edit distance reads as a + // legitimate word rather than a slip. + aliases: { helpText: 'help', hint: 'help', tooltip: 'help', description: 'help', name: 'label', title: 'label' }, + guidance: { + options: BULK_PARAM_NO_OPTIONS, + choices: BULK_PARAM_NO_OPTIONS, + values: BULK_PARAM_NO_OPTIONS, + }, + }, { + label: z.string().optional().describe('Translated bulk-dialog field label'), + help: z.string().optional().describe('Translated help text under the field (the def spells this `help`, not `helpText`)'), + placeholder: z.string().optional().describe('Translated bulk-dialog field placeholder'), + })).optional().describe('Bulk-dialog parameter translations keyed by param name (`BulkActionParamSchema.name`)'), + })).optional().describe('Selection-bar translations keyed by bulk-action def name (`BulkActionDefSchema.name`)'), })).optional().describe('View translations keyed by view name'), /** From c403f512857af58f4f4c58545bbe4dfd280a8f84 Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 2 Sep 2026 01:10:41 +0000 Subject: [PATCH 2/6] feat(spec,objectql): give a custom validation rule's message a bundle key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object.validations[].message` is the sentence a rejected write returns, and the evaluator emitted it verbatim. A deployment with a complete `zh-CN` bundle therefore read platform-generated refusals in Chinese and author-written refusals in English *inside one 400 VALIDATION_FAILED envelope* — the built-in field catalog has resolved through the engine's i18n service since #3957, and only the authored half had nowhere to look. Adds `objects.._validations..message`, spelled by `objectValidationMessageKey` (the third member of the `objectFieldLabelKey` / `objectLabelKey` family) and read on the write path by a new `authoredRuleMessage` seat in the rule evaluator. ⚠️ No second i18n path into objectql. The lookup runs on the SAME `ValidationMessageContext.translate` hook — the engine's `i18nService`, bridged by `ObjectQLPlugin` — that `resolveFieldLabel` and `renderValidationMessage` already use. What was missing was a key shape, not a channel. All five authored-message emitters route through the one seat (`script`/`cross_field`, `state_machine`, `format`, and both `json_schema` arms); a nested `conditional` branch is addressed by the BRANCH's own name, and a PLATFORM-generated rejection (an unevaluable predicate) is deliberately left alone. This is not `validationMessages` (#4667, ADR-0049) coming back. That group was keyed by rule name at the bundle's TOP level — it could not tell two objects' rules apart — and, the reason it was retired, nothing read it. This address is object-scoped, sits beside `_views`/`_actions`/`_tabs`, and ships its reader in the same change. Its retired-key guidance is updated to point here instead of asserting that no route exists, and the `errors` tombstone with it. Key face is one key, measured: a rule also declares `label` (the admin listing entry) and `description` (administrative notes), neither of which reaches a rejected caller — declaring them would parse clean and translate nothing, so both carry `guidance`. Also corrects the `validation.message` liveness row, whose overrides clause named `validationMessages` — a route removed a major version ago. Part of #14253 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- .../src/validation/rule-validator.test.ts | 207 +++++++++++++++++- .../objectql/src/validation/rule-validator.ts | 96 ++++++-- packages/spec/authorable-surface/system.json | 1 + packages/spec/liveness/validation.json | 6 +- .../spec/src/system/i18n-resolver.test.ts | 78 +++++++ packages/spec/src/system/i18n-resolver.ts | 23 ++ packages/spec/src/system/translation.zod.ts | 84 ++++++- 7 files changed, 460 insertions(+), 35 deletions(-) diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index feb79797e4..0eee35f134 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -2181,9 +2181,10 @@ describe('conditional enforcement', () => { /** * #3957 — this evaluator's BUILT-IN messages had the same defect as the field - * validator's: hardcoded English with the API field name. An author-written - * `rule.message` is untouched (it is already in the author's language); only - * the platform's own sentences are localized. + * validator's: hardcoded English with the API field name. Only the platform's + * own sentences are localized here; an author-written `rule.message` is + * untouched unless the bundle explicitly addresses it — see the `_validations` + * describe below (#14253), which is the ONLY thing that can override it. */ describe('evaluateValidationRules — built-in messages are localized (#3957)', () => { const zh = { locale: 'zh-CN', objectName: 'mes_invoice' }; @@ -2253,10 +2254,13 @@ describe('evaluateValidationRules — built-in messages are localized (#3957)', }); /** - * An author who wrote their own message owns the wording — localizing over it - * would silently replace their (often already-translated) text. + * An author who wrote their own message owns the wording, and the BUILT-IN + * catalog never speaks over it: a `zh-CN` context with no i18n service (and + * so no `_validations` entry to find) leaves the authored sentence exactly as + * written. The one thing that CAN replace it is an explicit bundle entry at + * `objects.._validations..message` — see the #14253 describe below. */ - it('never overrides an author-written rule message', () => { + it('never overrides an author-written rule message from the built-in catalog', () => { const schema = { fields: { status: { type: 'select', label: '状态' } }, validations: [{ @@ -2286,3 +2290,194 @@ describe('evaluateValidationRules — built-in messages are localized (#3957)', expect(failing(schema, { status: 'sent' }, 'insert')[0].message).toBe('Amount is required'); }); }); + +/** + * #14253 — an author-written `validations[].message` in the caller's language. + * + * The evaluator emitted the authored sentence verbatim, so a deployment with a + * complete `zh-CN` bundle read platform-generated refusals in Chinese and + * author-written refusals in English **inside one error envelope**. The + * built-in field catalog has resolved through `messages.translate` since #3957; + * what was missing was a key shape for an author's own rule, not a channel. + * + * These tests drive the SAME `ValidationMessageContext.translate` hook the + * built-in catalog uses (the engine's `i18nService`, `II18nService.t` + * semantics: the key is echoed back on a miss). If a second i18n path were ever + * added to objectql, this fixture would keep passing through the old one — so + * the address is asserted explicitly, not just the output. + */ +describe('evaluateValidationRules — authored rule messages resolve from the bundle (#14253)', () => { + /** An `II18nService.t`-shaped lookup over a flat key→string map. */ + const service = (entries: Record) => { + const seen: string[] = []; + return { + seen, + t: (key: string, locale: string) => { + seen.push(`${locale}|${key}`); + return entries[`${locale}|${key}`] ?? key; + }, + }; + }; + + const failing = ( + schema: any, + data: Record, + mode: 'insert' | 'update', + opts: Record = {}, + ) => { + try { + evaluateValidationRules(schema, data, mode, opts); + } catch (e) { + return (e as ValidationError).fields; + } + throw new Error('expected a ValidationError'); + }; + + const scriptSchema = (message: string) => ({ + fields: { frequency: { type: 'select', label: '频率' } }, + validations: [{ + type: 'script', + name: 'standing_no_frequency', + message, + condition: "record.frequency == null || record.frequency == ''", + fields: ['frequency'], + }], + }); + + it('asks for objects.._validations..message, and returns what it gets', () => { + const i18n = service({ + 'zh-CN|objects.duly_duty._validations.standing_no_frequency.message': + '常规任务必须设置频率。', + }); + const [err] = failing(scriptSchema('A standing duty must declare a frequency.'), {}, 'insert', { + messages: { locale: 'zh-CN', objectName: 'duly_duty', translate: i18n.t }, + }); + expect(err.message).toBe('常规任务必须设置频率。'); + expect(i18n.seen).toContain('zh-CN|objects.duly_duty._validations.standing_no_frequency.message'); + // The wire vocabulary is unchanged — ADR-0114's `code` does not split + // because a sentence is now translatable. + expect(err.code).toBe('rule_violation'); + expect(err.field).toBe('frequency'); + }); + + it('keeps the authored sentence when the bundle has no entry (the key echoes back)', () => { + const i18n = service({}); + const [err] = failing(scriptSchema('A standing duty must declare a frequency.'), {}, 'insert', { + messages: { locale: 'zh-CN', objectName: 'duly_duty', translate: i18n.t }, + }); + expect(err.message).toBe('A standing duty must declare a frequency.'); + }); + + it('does not consult the bundle without an object name or a locale', () => { + const i18n = service({ + 'zh-CN|objects.duly_duty._validations.standing_no_frequency.message': '常规任务必须设置频率。', + }); + expect(failing(scriptSchema('authored'), {}, 'insert', { + messages: { locale: 'zh-CN', translate: i18n.t }, + })[0].message).toBe('authored'); + expect(failing(scriptSchema('authored'), {}, 'insert', { + messages: { objectName: 'duly_duty', translate: i18n.t }, + })[0].message).toBe('authored'); + expect(i18n.seen).toEqual([]); + }); + + it('survives a throwing i18n service — a 400 must not become a 500', () => { + const [err] = failing(scriptSchema('authored'), {}, 'insert', { + messages: { + locale: 'zh-CN', + objectName: 'duly_duty', + translate: () => { throw new Error('i18n exploded'); }, + }, + }); + expect(err.message).toBe('authored'); + }); + + it('reaches the state-machine, format and json_schema emitters too', () => { + const i18n = service({ + 'zh-CN|objects.duly_duty._validations.lifecycle.message': '已完成的任务不能退回。', + 'zh-CN|objects.duly_duty._validations.contact_email.message': '联系邮箱格式不正确。', + 'zh-CN|objects.duly_duty._validations.payload_shape.message': '负载结构不符合约定。', + }); + const messages = { locale: 'zh-CN', objectName: 'duly_duty', translate: i18n.t }; + + const fsm = { + fields: { status: { type: 'select', label: '状态' } }, + validations: [{ + type: 'state_machine', name: 'lifecycle', message: 'A done duty cannot be reopened.', + field: 'status', transitions: { done: [] }, + }], + }; + expect(failing(fsm, { status: 'open' }, 'update', { messages, previous: { status: 'done' } })[0].message) + .toBe('已完成的任务不能退回。'); + + const format = { + fields: { contact: { type: 'text', label: '联系邮箱' } }, + validations: [{ + type: 'format', name: 'contact_email', message: 'Contact email is malformed.', + field: 'contact', format: 'email', + }], + }; + expect(failing(format, { contact: 'not-an-email' }, 'insert', { messages })[0].message) + .toBe('联系邮箱格式不正确。'); + + const json = { + fields: { payload: { type: 'json', label: '负载' } }, + validations: [{ + type: 'json_schema', name: 'payload_shape', message: 'Payload does not match the contract.', + field: 'payload', schema: { type: 'object', required: ['kind'] }, + }], + }; + expect(failing(json, { payload: { other: 1 } }, 'insert', { messages })[0].message) + .toBe('负载结构不符合约定。'); + }); + + it('addresses a nested conditional branch by the BRANCH\'s own name', () => { + const i18n = service({ + 'zh-CN|objects.duly_duty._validations.approver_required.message': '企业客户需要审批人。', + // The wrapping conditional's own message never reaches a caller; an entry + // for it must not be what the branch renders. + 'zh-CN|objects.duly_duty._validations.enterprise_gate.message': 'WRONG — the wrapper', + }); + const schema = { + fields: { account_type: { type: 'select' }, approver: { type: 'text' } }, + validations: [{ + type: 'conditional', + name: 'enterprise_gate', + message: 'Enterprise validation', + when: "record.account_type == 'enterprise'", + then: { + type: 'script', + name: 'approver_required', + message: 'Enterprise accounts require an approver.', + condition: 'record.approver == null', + fields: ['approver'], + }, + }], + }; + const [err] = failing(schema, { account_type: 'enterprise', approver: null }, 'insert', { + messages: { locale: 'zh-CN', objectName: 'duly_duty', translate: i18n.t }, + }); + expect(err.message).toBe('企业客户需要审批人。'); + }); + + it('leaves a PLATFORM-generated rejection alone — only the authored sentence is addressed', () => { + // An unevaluable predicate produces the platform's own sentence, not the + // author's, so `_validations..message` must not overwrite it: the + // caller needs to be told the rule is broken, not told the rule's verdict. + const i18n = service({ + 'zh-CN|objects.duly_duty._validations.broken.message': '这条不该出现。', + }); + const schema = { + fields: { frequency: { type: 'select' } }, + validations: [{ + type: 'script', name: 'broken', message: 'authored', + condition: { dialect: 'cel', source: 'this is (( not valid' }, fields: ['frequency'], + }], + }; + const [err] = failing(schema, {}, 'insert', { + messages: { locale: 'zh-CN', objectName: 'duly_duty', translate: i18n.t }, + }); + expect(err.message).toMatch(/could not be evaluated/); + expect(err.message).not.toBe('这条不该出现。'); + }); +}); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 52b08b30fe..2b1f389c3e 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -202,7 +202,7 @@ import { AUDIT_PROVENANCE_FIELDS, RUNTIME_OWNED_FIELD_TYPES } from '@objectstack // reports it unconditionally, even under `systemFields: false`), and the // engine's whole by-id addressing reads that key (`idAddressesThisRow`, // `addressKey: 'id'`). -import { SystemFieldName } from '@objectstack/spec/system'; +import { SystemFieldName, objectValidationMessageKey } from '@objectstack/spec/system'; import Ajv, { type ValidateFunction } from 'ajv'; // #5029 — `format` is NOT built into ajv 8; it ships in this separate package. // See the `const ajv` note below for why the runtime registers it. @@ -286,7 +286,8 @@ interface RuleContext { logger: EvaluateRulesOptions['logger']; /** Declared fields — the source of a violation's display label (#3957). */ fields: Record | undefined; - /** Locale + translation hooks for the BUILT-IN messages (#3957). */ + /** Locale + translation hooks: the BUILT-IN messages (#3957) and the + * authored `rule.message` (#14253) — one hook, two message sources. */ messages: ValidationMessageContext | undefined; } @@ -389,10 +390,13 @@ export interface EvaluateRulesOptions { */ skipStateMachine?: boolean; /** - * Locale + translation hooks for this evaluator's BUILT-IN messages (#3957) — - * the `requiredWhen` required-check, per-option gating, and the state-machine - * fallbacks. An author-written `rule.message` is never touched: it is already - * in whatever language its author chose. + * Locale + translation hooks for this evaluator's messages — the BUILT-IN + * ones (#3957: the `requiredWhen` required-check, per-option gating and the + * state-machine fallbacks) and, through the SAME hook, an author-written + * `rule.message` looked up at `objects.._validations..message` + * (#14253). Until that key existed the authored half had nowhere to look, so + * one rejection envelope could carry platform text in the caller's language + * beside authored text in the source language. */ messages?: ValidationMessageContext; } @@ -1871,6 +1875,52 @@ export function evaluateValidationRules( if (errors.length > 0) throw new ValidationError(errors); } +/** + * The author-written rule message in the caller's locale (#14253). + * + * `object.validations[].message` is the sentence a rejected write returns, and + * it used to be emitted verbatim. A deployment with a full `zh-CN` bundle + * therefore read platform-generated refusals in Chinese and author-written ones + * in English, side by side in one `400 VALIDATION_FAILED` envelope: the + * built-in field catalog has resolved through this same context since #3957, + * and only the authored half had nowhere to look. + * + * ## What this is NOT + * + * It is not a second i18n path into objectql. The lookup runs on the SAME + * `ValidationMessageContext.translate` hook — the engine's `i18nService`, + * bridged by `ObjectQLPlugin` — that `resolveFieldLabel` and + * `renderValidationMessage` already use. What was missing was a key shape for + * an author-written rule, not a channel to resolve one. + * + * The authored message is handed over verbatim on a miss and the translation is + * handed over verbatim on a hit: no `{{…}}` interpolation, because an authored + * message has no parameter contract and never had one. Only the language + * changes. + * + * `rule.name` is the address — including a nested `conditional` branch's own + * name, since the branch is the rule whose message the caller sees. + */ +function authoredRuleMessage( + rule: Pick, + messages: ValidationMessageContext | undefined, +): string { + if (messages?.translate && messages.objectName && messages.locale + && typeof rule.name === 'string' && rule.name.length > 0) { + const key = objectValidationMessageKey(messages.objectName, rule.name); + try { + const translated = messages.translate(key, messages.locale); + // II18nService echoes the key back on a miss. + if (typeof translated === 'string' && translated.length > 0 && translated !== key) { + return translated; + } + } catch { + // A misbehaving i18n service must not turn a 400 into a 500. + } + } + return rule.message; +} + /** * Dispatch a single rule to its checker, returning the violation (or null). * Shared by the top-level loop and by `checkConditional`, which recurses into @@ -1884,11 +1934,11 @@ function evaluateRule(rule: BaseRule, ctx: RuleContext): FieldValidationError | return checkStateMachine(rule as StateMachineRule, ctx.mode, ctx.data, ctx.previous, ctx); case 'script': case 'cross_field': - return checkPredicate(rule as PredicateRule, ctx.merged, ctx.previous, ctx.logger); + return checkPredicate(rule as PredicateRule, ctx.merged, ctx.previous, ctx.logger, ctx.messages); case 'format': - return checkFormat(rule as FormatRule, ctx.data, ctx.logger); + return checkFormat(rule as FormatRule, ctx.data, ctx.logger, ctx.messages); case 'json_schema': - return checkJsonSchema(rule as JsonSchemaRule, ctx.data, ctx.logger); + return checkJsonSchema(rule as JsonSchemaRule, ctx.data, ctx.logger, ctx.messages); case 'conditional': return checkConditional(rule as ConditionalRule, ctx); default: @@ -1917,8 +1967,10 @@ function checkStateMachine( previous: Record | undefined, ctx?: Pick, ): FieldValidationError | null { - // An author-written `rule.message` wins untouched — it is already in the - // language its author chose. Only the FALLBACK is ours to localize (#3957). + // An author-written `rule.message` wins over the built-in fallback, and is + // itself resolved against `objects.._validations..message` before it + // is emitted (#14253) — the authored text is the last resort, not the first. + // The FALLBACK is localized through the built-in catalog (#3957). const fallback = ( code: 'invalid_initial_state' | 'invalid_transition', constraint: Record, @@ -1929,7 +1981,7 @@ function checkStateMachine( return { field: rule.field, code, - message: rule.message, + message: authoredRuleMessage(rule, ctx?.messages), label: resolveFieldLabel(rule.field, def, ctx?.messages), }; } @@ -2024,6 +2076,7 @@ function checkPredicate( record: Record, previous: Record | undefined, logger: EvaluateRulesOptions['logger'], + messages?: ValidationMessageContext, ): FieldValidationError | null { const expr = toExpression(rule.condition); const result = ExpressionEngine.evaluate(expr, { @@ -2046,7 +2099,7 @@ function checkPredicate( return { field, code: 'rule_violation', - message: rule.message, + message: authoredRuleMessage(rule, messages), }; } return null; @@ -2074,6 +2127,7 @@ function checkFormat( rule: FormatRule, data: Record, logger: EvaluateRulesOptions['logger'], + messages?: ValidationMessageContext, ): FieldValidationError | null { if (!(rule.field in data)) return null; const value = data[rule.field]; @@ -2088,11 +2142,11 @@ function checkFormat( logger?.warn?.(`Validation rule '${rule.name}' has an invalid regex — skipped`); return null; } - if (!re.test(str)) return formatViolation(rule); + if (!re.test(str)) return formatViolation(rule, messages); } if (rule.format && !matchesNamedFormat(rule.format, str)) { - return formatViolation(rule); + return formatViolation(rule, messages); } return null; } @@ -2123,8 +2177,11 @@ function matchesNamedFormat(format: FormatRule['format'], str: string): boolean } } -function formatViolation(rule: FormatRule): FieldValidationError { - return { field: rule.field, code: 'invalid_format', message: rule.message }; +function formatViolation( + rule: FormatRule, + messages: ValidationMessageContext | undefined, +): FieldValidationError { + return { field: rule.field, code: 'invalid_format', message: authoredRuleMessage(rule, messages) }; } /** @@ -2138,6 +2195,7 @@ function checkJsonSchema( rule: JsonSchemaRule, data: Record, logger: EvaluateRulesOptions['logger'], + messages?: ValidationMessageContext, ): FieldValidationError | null { if (!(rule.field in data)) return null; let value = data[rule.field]; @@ -2147,7 +2205,7 @@ function checkJsonSchema( try { value = JSON.parse(value); } catch { - return { field: rule.field, code: 'invalid_json', message: rule.message }; + return { field: rule.field, code: 'invalid_json', message: authoredRuleMessage(rule, messages) }; } } @@ -2166,7 +2224,7 @@ function checkJsonSchema( } if (!validate(value)) { - return { field: rule.field, code: 'json_schema_violation', message: rule.message }; + return { field: rule.field, code: 'json_schema_violation', message: authoredRuleMessage(rule, messages) }; } return null; } diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index d67930f673..a07a3f2570 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -888,6 +888,7 @@ "system/ObjectTranslationData:_actions", "system/ObjectTranslationData:_sections", "system/ObjectTranslationData:_tabs", + "system/ObjectTranslationData:_validations", "system/ObjectTranslationData:_views", "system/ObjectTranslationData:description", "system/ObjectTranslationData:fields", diff --git a/packages/spec/liveness/validation.json b/packages/spec/liveness/validation.json index 8d5b57e229..640943c682 100644 --- a/packages/spec/liveness/validation.json +++ b/packages/spec/liveness/validation.json @@ -52,9 +52,9 @@ }, "message": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/objectql/src/validation/rule-validator.ts#checkPredicate (`message: rule.message` on the violation); packages/objectql/src/validation/rule-validator.ts#checkStateMachine (an author-written message wins over the generated fallback, untouched); packages/objectql/src/engine.ts#update (the bulk per-row path re-wraps the authored text with the row id before rethrowing `ValidationError`)", - "note": "the author-written violation text carried on every FieldValidationError (surfaced as 400 VALIDATION_FAILED); per-deployment overrides resolve via validationMessages in the translation bundle (#3957) without touching the authored value. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED, both legs — `:676` had rotted into the ADR-0124 docblock (see `name`), and `engine.ts:3703` onto a docblock about a probe's own failure disposition, ~6,990 lines above the only place engine.ts touches this key. Recorded honestly rather than dressed up: the engine leg is NARROW — it re-wraps the message with a record id, it neither authors nor resolves it — and `#update` is a WEAK anchor, because `update` occurs throughout that file and so cannot go red if the method is deleted. The rule-validator anchors are what carry this row. Re-closed by hand against 8cb96ec41." + "verifiedAt": "2026-09-02", + "evidence": "packages/objectql/src/validation/rule-validator.ts#checkPredicate (`message: authoredRuleMessage(rule, messages)` on the violation); packages/objectql/src/validation/rule-validator.ts#authoredRuleMessage (resolves the authored text against `objects.._validations..message` through the engine's i18n service, falling back to the authored value); packages/objectql/src/validation/rule-validator.ts#checkStateMachine (an author-written message wins over the generated fallback); packages/objectql/src/engine.ts#update (the bulk per-row path re-wraps the authored text with the row id before rethrowing `ValidationError`)", + "note": "the author-written violation text carried on every FieldValidationError (surfaced as 400 VALIDATION_FAILED). 2026-09-02 (#14253): the overrides clause was CORRECTED, not extended — it read \"per-deployment overrides resolve via validationMessages in the translation bundle (#3957)\", and `validationMessages` had been removed in 17.0.0 (#4667), so the cell described a route that had not existed for a major version. The route now named is real and has a reader in the same change: `objects.._validations..message`, resolved by `authoredRuleMessage` through the SAME i18n service #3957 wired for built-in messages and field labels — a key shape, not a second channel. The authored value is still untouched at rest; the lookup happens as the violation is built, and a miss returns it verbatim. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED, both legs — `:676` had rotted into the ADR-0124 docblock (see `name`), and `engine.ts:3703` onto a docblock about a probe's own failure disposition, ~6,990 lines above the only place engine.ts touches this key. Recorded honestly rather than dressed up: the engine leg is NARROW — it re-wraps the message with a record id, it neither authors nor resolves it — and `#update` is a WEAK anchor, because `update` occurs throughout that file and so cannot go red if the method is deleted. The rule-validator anchors are what carry this row. Re-closed by hand against 8cb96ec41." }, "type": { "status": "live", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index c5f8d073b2..246e4d543b 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -15,6 +15,7 @@ import { resolveObjectFieldLabels, toLocaleDescriptors, normalizeSupportedLocales, + objectValidationMessageKey, } from './i18n-resolver'; // #4854 — the served view document is whatever THIS composer emits, so the // fixture below is generated by it rather than transcribed from a bug report. @@ -2962,3 +2963,80 @@ describe('ObjectTranslationDataSchema — _views..bulkActions (#14253)', ( })).toThrow(/_actions\.\.label/); }); }); + +/** + * Custom validation-rule messages (#14253, surface 2). + * + * The address only — the resolution lives on the write path, in + * `@objectstack/objectql`'s rule evaluator, which asks the EXISTING + * `i18nService` channel (#3957) with this key. Spelling it here is what keeps + * the two ends from drifting: `objectFieldLabelKey` / `objectLabelKey` exist + * for the same reason. + */ +describe('objectValidationMessageKey (#14253)', () => { + it('spells the address the `_validations` group declares', () => { + expect(objectValidationMessageKey('duly_duty', 'standing_no_frequency')) + .toBe('objects.duly_duty._validations.standing_no_frequency.message'); + }); + + it('lands on the value a bundle carries at that path', () => { + const bundle = { + 'zh-CN': { + objects: { + duly_duty: { + _validations: { standing_no_frequency: { message: '常规任务必须设置频率。' } }, + }, + }, + }, + } satisfies TranslationBundle; + // Walk the key the way an II18nService does, so the string a service would + // return is proven to be the string the schema stores — one address, not + // two that happen to look alike. + const key = objectValidationMessageKey('duly_duty', 'standing_no_frequency'); + const walked = key.split('.').reduce((node, seg) => node?.[seg], bundle['zh-CN']); + expect(walked).toBe('常规任务必须设置频率。'); + }); +}); + +describe('ObjectTranslationDataSchema — _validations (#14253)', () => { + it('accepts a rule message keyed by rule name', () => { + const data = ObjectTranslationDataSchema.parse({ + _validations: { standing_no_frequency: { message: '常规任务必须设置频率。' } }, + }); + expect(data._validations?.standing_no_frequency.message).toBe('常规任务必须设置频率。'); + }); + + it('renames the un-prefixed spelling onto the group', () => { + expect(() => ObjectTranslationDataSchema.parse({ + validations: { standing_no_frequency: { message: 'x' } }, + })).toThrow(/validations[\s\S]*_validations/); + }); + + it('renames the near-miss spellings of `message`', () => { + for (const spelling of ['text', 'errorMessage', 'violationMessage']) { + expect(() => ObjectTranslationDataSchema.parse({ + _validations: { r: { [spelling]: 'x' } }, + })).toThrow(new RegExp(`${spelling}[\\s\\S]*message`)); + } + }); + + it('refuses `label` and `description` with the reason — neither reaches a rejected caller', () => { + let msg = ''; + try { + ObjectTranslationDataSchema.parse({ _validations: { r: { label: '规则' } } }); + } catch (e) { msg = String(e); } + expect(msg).toMatch(/admin rule listing/); + + msg = ''; + try { + ObjectTranslationDataSchema.parse({ _validations: { r: { description: '说明' } } }); + } catch (e) { msg = String(e); } + expect(msg).toMatch(/administrative note/); + }); + + it('refuses `condition` — a CEL predicate is the same expression in every locale', () => { + expect(() => ObjectTranslationDataSchema.parse({ + _validations: { r: { condition: 'record.x == null' } }, + })).toThrow(/CEL predicate, not display copy/); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 81209f0f7b..a7b16debc2 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1704,6 +1704,29 @@ export function objectLabelKey(objectName: string): string { return `objects.${objectName}.label`; } +/** + * Dot-notation i18n key for a custom validation rule's rejection message — + * `objects.._validations..message` (#14253). + * + * The third member of the {@link objectFieldLabelKey} / {@link objectLabelKey} + * family, and spelled here for the same reason: the consumer holds an + * `II18nService` (which takes a key), not a `TranslationBundle`. Used by the + * ObjectQL rule evaluator so an author-written `validations[].message` reaches + * a rejected caller in that caller's language, the way the built-in field + * catalog already does (#3957). + * + * `ruleName` is `ValidationRuleSchema.name` — including a nested `conditional` + * branch's own name, which is what the branch's violation is emitted under. + * + * ⚠️ This is deliberately NOT the retired `validationMessages` group (#4667, + * ADR-0049). That one was keyed by rule name at the bundle's top level, so it + * could not distinguish two objects' rules, and — the reason it was retired — + * nothing read it. This address is object-scoped and has a reader. + */ +export function objectValidationMessageKey(objectName: string, ruleName: string): string { + return `objects.${objectName}._validations.${ruleName}.message`; +} + export function resolveObjectFieldLabels( data: TranslationData | undefined, objectName: string, diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 1e17dd58ca..29a3edfb3b 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -172,7 +172,8 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ aliases: { // The group prefixes are the whole point of the `_`-prefixed convention, // and the un-prefixed spelling is the one an author reaches for first. - views: '_views', actions: '_actions', sections: '_sections', + views: '_views', actions: '_actions', sections: '_sections', validations: '_validations', + validationRules: '_validations', rules: '_validations', plural: 'pluralLabel', labelPlural: 'pluralLabel', name: 'label', title: 'label', columns: 'fields', properties: 'fields', attributes: 'fields', }, @@ -383,6 +384,73 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ }, { label: z.string().optional().describe('Translated tab label'), })).optional().describe('Filter-preset tab translations keyed by tab name'), + + /** + * Custom validation-rule messages keyed by rule name + * (`ValidationRuleSchema.name`, snake_case). + * + * Convention (resolved on the WRITE path by the ObjectQL rule evaluator): + * objects.._validations..message + * + * **The hole this closes (#14253).** `object.validations[].message` is the + * sentence a rejected write returns, and the evaluator emitted it verbatim. + * A deployment therefore got platform-generated refusals in the caller's + * language — the built-in field catalog has shipped `zh-CN` since #3957 — and + * author-written refusals in the source language, side by side in one error + * envelope. + * + * **This is not `validationMessages` coming back.** That group (retired in + * 17.0.0, #4667) was keyed by rule name at the TOP level, so it could not + * tell two objects' rules apart, and — the reason it was retired — nothing + * read it. This one is object-scoped, sits beside `_views` / `_actions` / + * `_tabs`, and has a reader in the same change: `objectValidationMessageKey` + * (`system/i18n-resolver.ts`) spells the address, and the rule evaluator asks + * the **existing** `i18nService` channel (`engine.ts`'s `setI18nService`, + * #3957 — the one that already localizes built-in messages and field labels). + * No second i18n path into objectql. + * + * **`messages['validation.field.*']` is a different thing** and stays as it + * is: it overrides the platform's BUILT-IN field catalog + * (`BUILTIN_VALIDATION_MESSAGES`), which is keyed by constraint, not by rule. + * It cannot address an author's own rule. + * + * **The key face is one key, measured.** A rule declares `label` (its entry + * in the admin rule listing) and `description` (administrative notes) as well + * as `message` — neither reaches a rejected caller, and declaring them here + * would parse clean and translate nothing, the ADR-0078 shape this file keeps + * paying to remove. Both carry `guidance` instead. + * + * A nested `conditional` branch rule carries its own `name` and is addressed + * by it; the wrapping `conditional`'s own `message` never reaches a user (the + * branch supplies the violation), so it has nothing to translate. + */ + _validations: z.record(z.string(), strictObject({ + surface: 'this validation rule translation', + history: TRANSLATION_HISTORY, + aliases: { + text: 'message', error: 'message', errorMessage: 'message', errorText: 'message', + violation: 'message', violationMessage: 'message', msg: 'message', + }, + guidance: { + label: + "`label` is the rule's entry in the admin rule listing, not the text a rejected write returns " + + '— nothing resolves it through the bundle, so a key here would parse clean and translate ' + + 'nothing. Only `message` is addressed on this surface.', + description: + "`description` is the rule's administrative note (the business reason, for whoever maintains " + + 'the rule) and never reaches a user. Translate `message`, the sentence a rejected write ' + + 'returns.', + condition: + '`condition` / `when` is a CEL predicate, not display copy — it is the same expression in ' + + 'every locale. Only `message` is translatable on a validation rule.', + when: + '`when` / `condition` is a CEL predicate, not display copy — it is the same expression in ' + + 'every locale. Only `message` is translatable on a validation rule.', + }, + }, { + message: z.string().optional() + .describe("Translated rejection message — overlays the rule's authored `message` on every rejected write"), + })).optional().describe('Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`)'), }).describe('Translation data for a single object')); export type ObjectTranslationData = z.input; @@ -463,10 +531,12 @@ const TRANSLATION_KEY_GUIDANCE: Record._validations..message' (#14253), which the write " + + 'path resolves. Delete this key. Run ' + '`os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', o: "`o` is the retired object-first dialect, which no resolver reads — use 'objects.'", app: "`app` is the retired object-first dialect, which no resolver reads — use 'apps.'", @@ -479,9 +549,9 @@ const TRANSLATION_KEY_GUIDANCE: Record._validations..message' (#14253); omit `errors`.", _globalOptions: "`_globalOptions` is the retired object-first dialect — use 'objects..fields..options'", _meta: "`_meta` is the retired object-first dialect — use the top-level 'locale' field (on a bundle, the locale is the map key)", namespace: '`namespace` is not part of the translation contract — omit it (ADR-0129 D3 retired the separate namespace declaration platform-wide)', From 34d57407bdbc1aae7fda2c91e686a69c348cbf1c Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 2 Sep 2026 01:23:00 +0000 Subject: [PATCH 3/6] feat(spec): translate dataset labels via translateDataset + a datasets group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dataset reads like a back-office definition, but a measure label is drawn ON THE DASHBOARD — under every metric tile and on every chart axis. `dataset` was neither in `TRANSLATABLE_METADATA_TYPES` nor addressed by any bundle group, so a translated dashboard rendered Chinese tile titles (those are `dashboards..widgets..*`) with `Untouched > 14 days` directly beneath them. Not a drifted key: no key. Adds `datasets..{label,description,dimensions..label, measures..label}` and `translateDataset`, registered in `METADATA_DOCUMENT_TRANSLATORS`. That registration is the whole wiring: `TRANSLATABLE_METADATA_TYPES` is DERIVED from the table and `@objectstack/rest` reads the derived set, so the REST boundary follows with nothing else to remember (#3786). No second hand-maintained list was found — the derivation is intact. Top-level rather than nested under `dashboards` because a dataset is the ONE definition every presentation binds to by reference (ADR-0021 D1): the same measure is drawn by N widgets across M dashboards, and a dataset no dashboard references would otherwise be unaddressable. The four keys are `I18nLabelSchema` at the authoring site, so a dataset's copy may already be an inline `{ en, 'zh-CN' }` map (#5728). `translateDataset` writes ONLY where the bundle answers — the same rule `translatePage` follows — so an uncovered inline map is left intact rather than flattened to one language, and the member arrays keep their identity when nothing matched. Key face measured against `DatasetSchema`: a dimension and a measure each declare `label` and nothing else display-shaped, which `dataset.zod.ts` states at the authoring site too ("its author-facing text is `label`. `description` is declared on the DATASET itself"). `description` therefore lives on the dataset and carries `guidance` below it. Liveness: the `datasets` group is seeded LIVE and DRILLED (label / description / dimensions / measures) with its reader in the same change. The ledger's own header sentence is corrected with it — it still called `validationMessages` "the one dead group" a major version after #4667 removed it. Part of #14253 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- content/docs/references/api/protocol.mdx | 1 + .../docs/references/system/translation.mdx | 36 +++- ...07-unknown-key-strictness-ledger.counts.md | 2 +- packages/spec/api-surface/system.json | 6 + packages/spec/authorable-surface/system.json | 2 + packages/spec/export-origins/system.json | 6 + packages/spec/liveness/README.md | 2 +- packages/spec/liveness/state-counts.md | 4 +- packages/spec/liveness/translation.json | 34 +++- .../spec/src/system/i18n-resolver.test.ts | 176 +++++++++++++++++- packages/spec/src/system/i18n-resolver.ts | 136 ++++++++++++++ packages/spec/src/system/translation.zod.ts | 99 +++++++++- 12 files changed, 493 insertions(+), 11 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index f74675a191..837a525011 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1565,6 +1565,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **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 | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index c0d5aa4d45..47cc4708c0 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -90,10 +90,11 @@ Translation data for a single object | **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 | +| **_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: `ObjectTranslationData.fields[string]` @@ -113,6 +114,7 @@ Translation data for a single field | **label** | `string` | optional | Translated view label | | **description** | `string` | optional | Translated view description | | **emptyState** | `{ title?: string; message?: string }` | optional | Translated empty-state copy shown when the view has no rows | +| **bulkActions** | `Record }>` | optional | Selection-bar translations keyed by bulk-action def name (`BulkActionDefSchema.name`) | ### Nested Shape: `ObjectTranslationData._actions[string]` @@ -138,6 +140,12 @@ Translation data for a single field | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated tab label | +### Nested Shape: `ObjectTranslationData._validations[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **message** | `string` | optional | Translated rejection message — overlays the rule's authored `message` on every rejected write | + --- @@ -225,6 +233,7 @@ Translation data for objects, apps, and UI messages | **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 | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -241,10 +250,11 @@ Translation data for a single object | **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 | +| **_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: `TranslationData.apps[string]` @@ -274,6 +284,15 @@ Translation data for a single object | **actions** | `Record` | optional | Header action label translations keyed by action url/key | | **widgets** | `Record` | optional | Widget translations keyed by widget id | +### Nested Shape: `TranslationData.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: `TranslationData.pages[string]` | Property | Type | Required | Description | @@ -364,6 +383,7 @@ One locale of translations — the `translation` metadata type | **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 | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -390,10 +410,11 @@ Translation data for a single object | **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 | +| **_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: `TranslationItem.apps[string]` @@ -423,6 +444,15 @@ Translation data for a single object | **actions** | `Record` | optional | Header action label translations keyed by action url/key | | **widgets** | `Record` | optional | Widget translations keyed by widget id | +### Nested Shape: `TranslationItem.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: `TranslationItem.pages[string]` | Property | Type | Required | Description | 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 0fe672c84f..e54af33b83 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. | `kernel/` | 261 | | `qa/` | 6 | | `shared/` | 20 | -| `system/` | 364 | +| `system/` | 370 | diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 0c0a3576c4..7d0fc61fec 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -79,6 +79,8 @@ "BucketConfig (type)", "BucketConfigParsed (type)", "BucketConfigSchema (const)", + "BulkActionDefLike (interface)", + "BulkActionParamLike (interface)", "CLOUD_PROVIDED_OBJECT_NAMES (const)", "CORE_SERVICE_PROVIDER (const)", "CRDTMergeResult (type)", @@ -173,6 +175,8 @@ "DatabaseLevelIsolationStrategySchema (const)", "DatabaseProvider (type)", "DatabaseProviderSchema (const)", + "DatasetLike (interface)", + "DatasetMemberLike (interface)", "DeleteObjectOperation (type)", "DeployBundle (type)", "DeployBundleParsed (type)", @@ -790,6 +794,7 @@ "normalizeSupportedLocales (function)", "objectFieldLabelKey (function)", "objectLabelKey (function)", + "objectValidationMessageKey (function)", "operationMessageTranslationKey (function)", "preferredLocaleFromHeader (function)", "renderOperationMessage (function)", @@ -830,6 +835,7 @@ "translateAction (function)", "translateApp (function)", "translateDashboard (function)", + "translateDataset (function)", "translateFlow (function)", "translateMetadataDocument (function)", "translateObject (function)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index a07a3f2570..a96c863615 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1343,6 +1343,7 @@ "system/TranslationCoverageResult:translatedKeys", "system/TranslationData:apps", "system/TranslationData:dashboards", + "system/TranslationData:datasets", "system/TranslationData:flows", "system/TranslationData:globalActions", "system/TranslationData:messages", @@ -1367,6 +1368,7 @@ "system/TranslationItem:_provenance", "system/TranslationItem:apps", "system/TranslationItem:dashboards", + "system/TranslationItem:datasets", "system/TranslationItem:flows", "system/TranslationItem:globalActions", "system/TranslationItem:label", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index bf4ae65aa2..f536715935 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -79,6 +79,8 @@ "BucketConfig": "src/system/object-storage.zod.ts#BucketConfig (type)", "BucketConfigParsed": "src/system/object-storage.zod.ts#BucketConfigParsed (type)", "BucketConfigSchema": "src/system/object-storage.zod.ts#BucketConfigSchema (const)", + "BulkActionDefLike": "src/system/i18n-resolver.ts#BulkActionDefLike (interface)", + "BulkActionParamLike": "src/system/i18n-resolver.ts#BulkActionParamLike (interface)", "CLOUD_PROVIDED_OBJECT_NAMES": "src/system/constants/platform-object-names.ts#CLOUD_PROVIDED_OBJECT_NAMES (const)", "CORE_SERVICE_PROVIDER": "src/system/core-services.zod.ts#CORE_SERVICE_PROVIDER (const)", "CRDTMergeResult": "src/system/collaboration.zod.ts#CRDTMergeResult (type)", @@ -173,6 +175,8 @@ "DatabaseLevelIsolationStrategySchema": "src/system/tenant.zod.ts#DatabaseLevelIsolationStrategySchema (const)", "DatabaseProvider": "src/system/tenant.zod.ts#DatabaseProvider (type)", "DatabaseProviderSchema": "src/system/tenant.zod.ts#DatabaseProviderSchema (const)", + "DatasetLike": "src/system/i18n-resolver.ts#DatasetLike (interface)", + "DatasetMemberLike": "src/system/i18n-resolver.ts#DatasetMemberLike (interface)", "DeleteObjectOperation": "src/system/migration.zod.ts#DeleteObjectOperation (type)", "DeployBundle": "src/system/deploy-bundle.zod.ts#DeployBundle (type)", "DeployBundleParsed": "src/system/deploy-bundle.zod.ts#DeployBundleParsed (type)", @@ -790,6 +794,7 @@ "normalizeSupportedLocales": "src/system/i18n-resolver.ts#normalizeSupportedLocales (function)", "objectFieldLabelKey": "src/system/i18n-resolver.ts#objectFieldLabelKey (function)", "objectLabelKey": "src/system/i18n-resolver.ts#objectLabelKey (function)", + "objectValidationMessageKey": "src/system/i18n-resolver.ts#objectValidationMessageKey (function)", "operationMessageTranslationKey": "src/system/operation-message.ts#operationMessageTranslationKey (function)", "preferredLocaleFromHeader": "src/system/i18n-resolver.ts#preferredLocaleFromHeader (function)", "renderOperationMessage": "src/system/operation-message.ts#renderOperationMessage (function)", @@ -830,6 +835,7 @@ "translateAction": "src/system/i18n-resolver.ts#translateAction (function)", "translateApp": "src/system/i18n-resolver.ts#translateApp (function)", "translateDashboard": "src/system/i18n-resolver.ts#translateDashboard (function)", + "translateDataset": "src/system/i18n-resolver.ts#translateDataset (function)", "translateFlow": "src/system/i18n-resolver.ts#translateFlow (function)", "translateMetadataDocument": "src/system/i18n-resolver.ts#translateMetadataDocument (function)", "translateObject": "src/system/i18n-resolver.ts#translateObject (function)", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 169dc2c7bf..3e97d86541 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -887,7 +887,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | job | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowOrgOverride` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) **#4667**: `id` REMOVED (row deleted, strict removal) — nothing read it and its own describe() ("defaults to `name` when omitted") advertised an identity override that never existed; `name` is the scheduling key, the sys_job row key and the JobExecution.jobId stamp, so two jobs differing only in `id` were one job. **#7131** (PR #7425) takes the remaining two: `label` and `description` re-grade `dead` → `live` under the 2026-08-10 maintainer ruling that **designer previews count as consumers** — objectui's `JobPreview` had been reading `d.label` and `d.description` and rendering them as the preview card's title and subtitle the whole time, so the old "no runtime consumer" was a true statement about the *scheduler* and a false one about the system. **This row now has zero dead and the ADR-0033 exemption is still in force**, which is worth saying out loud because it is the first row in this table where those two facts hold together: the keys are still docs-shaped, still deliberately KEPT, still not `authorWarn`'d, and enforce-or-remove still has nothing to chase here. What changed is only that the exemption no longer has to carry the verdict — the measurement does. | | mapping | seeded 2026-08-01 (#4488) at 8/11 live; **0 dead since #4509** retired the three that were not. The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. RETIRED 17.0.0: `extractQuery` (authorWarn — "for export only" promised an export path no exporter implements) + `errorPolicy`/`batchSize`, which were dead AND **unwarnable** (schema defaults materialize at parse, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule). That unwarnability is why they went out in the 17.0.0 window rather than after a deprecation cycle: removal was the only channel that could ever reach the author. Rows DELETED, not tombstoned — MappingSchema is strict, so the keys left the walked shape | | seed | seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped | -| translation | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 10 of 11 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead). Dead 1 = `validationMessages` (authorWarn) at seeding: nothing resolved it, and #3778's own legacy-key migration table steered `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape. **#4667**: `validationMessages` REMOVED (row deleted) — removed from the shared translationDataShape(), so it retired at BOTH doors at once, closing the item-only asymmetry #3778's original guard had. #3778's own `errors` guidance was rewritten in the same change: it had been steering authors INTO this dead group. ⚠️ **What that left behind is this table's own worked example of the defect it warns about** (#7377): the same commit that deleted the `validationMessages` row wrote a count column of `dead 2` beside a sentence that named exactly one dead key — and that one was the key it had just removed. The real two were `name` and `label`, which the cell never mentioned. Measured at that commit, not inferred: the ledger's dead set there is `{name, label}` and `validationMessages` is absent from `props`. The number was right and the prose was false, in the same cell, on the day it was written — which is why the counts are now generated and this cell holds prose only. **#7131** (PR #7425) resolves it: `name` and `label` re-grade `dead` → `live` under the designer-previews-count-as-consumers ruling (objectui `TranslationPreview.tsx:67` reads `label` first and falls back to `name`, both rendering at `:100`), so the dead set is empty and there is no dead-set sentence left to keep true. As on `job`, the ADR-0033 docs-shaped exemption is untouched — nothing about enforce-or-remove moved. | +| translation | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 11 of 12 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead) — `flows` is the one that is not, and is `planned`. **#14253** added the twelfth, `datasets`, seeded LIVE and DRILLED (label / description / dimensions / measures) with its reader in the same change: `translateDataset` in the dispatch table, which is what `TRANSLATABLE_METADATA_TYPES` is derived from, so the REST boundary followed with nothing else to remember. The same change gave `objects.._views..bulkActions` and `objects.._validations..message` their first keys — both beneath the walk boundary, so neither adds a row here. Dead 1 = `validationMessages` (authorWarn) at seeding: nothing resolved it, and #3778's own legacy-key migration table steered `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape. **#4667**: `validationMessages` REMOVED (row deleted) — removed from the shared translationDataShape(), so it retired at BOTH doors at once, closing the item-only asymmetry #3778's original guard had. #3778's own `errors` guidance was rewritten in the same change: it had been steering authors INTO this dead group. ⚠️ **What that left behind is this table's own worked example of the defect it warns about** (#7377): the same commit that deleted the `validationMessages` row wrote a count column of `dead 2` beside a sentence that named exactly one dead key — and that one was the key it had just removed. The real two were `name` and `label`, which the cell never mentioned. Measured at that commit, not inferred: the ledger's dead set there is `{name, label}` and `validationMessages` is absent from `props`. The number was right and the prose was false, in the same cell, on the day it was written — which is why the counts are now generated and this cell holds prose only. **#7131** (PR #7425) resolves it: `name` and `label` re-grade `dead` → `live` under the designer-previews-count-as-consumers ruling (objectui `TranslationPreview.tsx:67` reads `label` first and falls back to `name`, both rendering at `:100`), so the dead set is empty and there is no dead-set sentence left to keep true. As on `job`, the ADR-0033 docs-shaped exemption is untouched — nothing about enforce-or-remove moved. | | qa | seeded 2026-08-10 (#6247) — **not a metadata type**: `TestSuiteSchema` is the FILE surface of the shipped `os test` command (`qa/*.test.json`), governed through the same `SPEC_ONLY_SCHEMAS` override as `query`/`webhook`/`validation`. It is in the table as the clearest worked example of a **false `dead` measurement**: #6247 reported the whole domain declared-but-inert on a grep that scanned only `*Schema` identifiers, and every consumer here reads the **type** names (`QA.TestSuite`, `QA.TestStep`, `QA.TestAction`) — so an entire execution chain (core's `TestRunner` + `HttpTestAdapter`, published via `export * as QA`, driven by a documented CLI command) read as zero consumers, and a retire ruling was issued on it before being withdrawn. The `evidenceScope` table one section up says no amount of specifier matching is sufficient for a negative claim; this is the same lesson for **identifier** matching. What was really wrong was narrower and real: the type was the contract and the schema had no `parse` site, so the CLI's `JSON.parse(content) as QA.TestSuite` cast admitted anything — ENFORCED in the same change (`TestSuiteSchema.safeParse` at the load site, pinned). Dead 5 = `name` (the file name is the suite identity; the CLI prints `path.basename`), `scenarios.name` (describe() says "for test reports"; every report carries `scenarioId` instead), `scenarios.description` (docs-shaped, kept), and the two on the enforce-or-remove worklist — `scenarios.tags` promises filtering that `os test`'s two flags cannot express, and `scenarios.requires` declares param/plugin preconditions nothing checks, so a suite naming a missing plugin runs anyway and fails as an unexplained HTTP error. Neither carries `authorWarn` and the omission is deliberate (`_authorWarnSkipped`): the lint walks stack **collections**, a QA suite is a loose file in no stack, so a warn flag here would emit nothing — a silent no-op inside the mechanism built to catch silent no-ops | | validation | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys are governed by the evaluator's tests, not ledger rows. **No longer a registered metadata kind** — #4509 retired it under ADR-0088 (a standalone rule had no object-binding key and every variant is `.strict()`, so it bound to nothing and gated no write; a state machine authored that way saved cleanly and did nothing). The rule VOCABULARY is untouched and fully live via `object.validations[]`, so the ledger keeps governing it through the gate's spec-only override, alongside `webhook` and `query`. The contrast with the two bridges in the same batch is the point: enforce-or-remove picked ENFORCE where the feature existed and only the wiring was missing, and REMOVE where the shape itself could not carry the feature | | api | seeded 2026-08-04 (#5271, part of #5206; PR #5312) — **not a metadata type until that same change made it one**, which is the row's point: governance and registration landed together, the treatment `datasource` did not get (#4487) and paid for with six inert keys found by hand. What #5206 measured before the fix: `api` was in neither `DEFAULT_METADATA_TYPE_REGISTRY` nor `BUILTIN_METADATA_TYPE_SCHEMAS`, so `saveMetaItem`'s `resolveOverlaySchema('api', …)` → `getMetadataTypeSchema('api')` returned `undefined` and took its own documented branch — an unregistered type is stored **unvalidated** — while `getMetaTypes()` could not enumerate the type at all, so Studio rendered neither list nor form. That issue names the shape precisely and it is the inverse of this ledger's usual one: **enforced but undeclared** (the matcher was already indexing these entries, #5089), where `dead` is declared-but-unenforced. The seeding pass classified 27 keys — live 25 / planned 2 / dead 0 — each cited `file:line` at the consumer layer that reads it: the MATCHER (`packages/metadata/src/endpoint-matcher.ts`) indexes `name`/`path`/`method`; the EXECUTOR (`packages/runtime/src/endpoint-executor.ts`) dispatches on `type` and reads `target`/`objectParams`; the POLICY chain (`packages/runtime/src/endpoint-policy.ts` + `security/inbound-rate-limit.ts`) enforces `authRequired`/`rateLimit`/`cacheTtl`; the MAPPING layer (`packages/runtime/src/api-mapping.ts`) applies `inputMapping`/`outputMapping`; and OpenAPI enrichment (`packages/rest/src/openapi-endpoints.ts`) emits `summary`/`description`. Timing was the reason it was cheap: #5040's E-series had built every one of those consumers and all of it was on main, so each key had a real evidence path rather than a promise. **Planned 2 = `inputMapping.transform` + `outputMapping.transform`, and `planned` rather than `dead` is load-bearing**: `dead` here means parsed with no consumer — a silent no-op — and these are the opposite, parsed and then LOUDLY REFUSED at publish (`endpoint-publish-gate.ts` mappingGate) and again at runtime, because no transformation-function registry exists anywhere in the platform. An author who writes one is told so and told what to do instead, so there is nothing for enforce-or-remove to chase; they stay in the vocabulary because admitting them needs a function registry **and** a sandbox ruling (#5040 §3.4), which is a design decision, not a key to quietly delete. Zero dead | diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index af92e0804f..f12dc96624 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -52,10 +52,10 @@ for both corollaries. | `job` | 15 | 0 | 0 | 0 | 0 | 15 | | `mapping` | 14 | 0 | 0 | 0 | 0 | 14 | | `seed` | 12 | 0 | 0 | 0 | 0 | 12 | -| `translation` | 19 | 0 | 0 | 0 | 2 | 21 | +| `translation` | 23 | 0 | 0 | 0 | 2 | 25 | | `validation` | 15 | 0 | 0 | 3 | 0 | 18 | | `api` | 25 | 0 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 0 | 5 | 0 | 9 | | `manifest` | 22 | 0 | 1 | 15 | 0 | 38 | -| **total** | **821** | **5** | **1** | **74** | **10** | **911** | +| **total** | **825** | **5** | **1** | **74** | **10** | **915** | diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 6ffe641c3f..a65717f8b2 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -1,6 +1,6 @@ { "type": "translation", - "_note": "TranslationItemSchema (#3778 — one locale's translations, the SAME groups the file-authored bundles use). NO LONGER A PIPE: the schema was a z.preprocess wrapping the retired object-first-dialect guard, which the gate's walker could not see through until #4488 fixed unwrap() to take the OUT side of a transform-input pipe — `translation` was literally unwalkable before this ledger. #4001 closed the shape with `.strict()` and folded the guard's ten prescriptions into the unknown-key `guidance`, so the preprocess is gone and the registered schema is a plain strict object. Consumer chain: runtime-authored items sync into the i18n adapter's authored layer (packages/core/src/fallbacks/authored-translation-sync.ts — at kernel:ready, on metadata:reloaded, and on translation mutations; #2591 closed the publish dead-end), file bundles load via service-i18n; both merge into ONE tree read by the spec resolvers (packages/spec/src/system/i18n-resolver.ts), the REST localization layer (translateMetaItem/translateMetaTypes), objectui's client resolvers (useObjectLabel/useSettingsLabel), and plugin-audit's summary localizer. WALK BOUNDARY: every group is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions (objects..fields..label, settings..keys..options., …) are governed by the resolvers cited per row, not by ledger rows. Note also the sync merges the RAW stored payload (authored-translation-sync.ts:155, not a schema re-parse), so the declared groups below are the CONTRACT while undeclared keys technically flow through on rows already stored — the resolvers read only the declared conventions. Since #4001 no NEW row can acquire one: the metadata door rejects an undeclared key instead of stripping it, so that residue is a finite set that only shrinks. 10 of 11 groups live; the one dead group (`validationMessages`) is pointed at by #3778's own legacy-key migration table, making it a shipped false signpost. Seeded 2026-08-01 (#4488). 2026-08-28 (#13003): all nine `path:NNN` citations in this file were re-anchored to their consuming symbols; EIGHT of the nine were wrong and every one of those was IN RANGE (the exception is `locale`, whose range still lands inside its reader). This ledger carried the batch's heaviest load of the OTHER silent class as well — nine further positions written as bare `:NNN` suffixes with no path in front of them, which `PATH_RE` never matches, so they degraded to prose that no check has ever resolved, bounded or key-checked.", + "_note": "TranslationItemSchema (#3778 — one locale's translations, the SAME groups the file-authored bundles use). NO LONGER A PIPE: the schema was a z.preprocess wrapping the retired object-first-dialect guard, which the gate's walker could not see through until #4488 fixed unwrap() to take the OUT side of a transform-input pipe — `translation` was literally unwalkable before this ledger. #4001 closed the shape with `.strict()` and folded the guard's ten prescriptions into the unknown-key `guidance`, so the preprocess is gone and the registered schema is a plain strict object. Consumer chain: runtime-authored items sync into the i18n adapter's authored layer (packages/core/src/fallbacks/authored-translation-sync.ts — at kernel:ready, on metadata:reloaded, and on translation mutations; #2591 closed the publish dead-end), file bundles load via service-i18n; both merge into ONE tree read by the spec resolvers (packages/spec/src/system/i18n-resolver.ts), the REST localization layer (translateMetaItem/translateMetaTypes), objectui's client resolvers (useObjectLabel/useSettingsLabel), and plugin-audit's summary localizer. WALK BOUNDARY: every group is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions (objects..fields..label, settings..keys..options., …) are governed by the resolvers cited per row, not by ledger rows. Note also the sync merges the RAW stored payload (authored-translation-sync.ts:155, not a schema re-parse), so the declared groups below are the CONTRACT while undeclared keys technically flow through on rows already stored — the resolvers read only the declared conventions. Since #4001 no NEW row can acquire one: the metadata door rejects an undeclared key instead of stripping it, so that residue is a finite set that only shrinks. 11 of 12 groups live; the twelfth, `datasets`, was seeded LIVE and DRILLED by #14253 with its reader (`translateDataset`) in the same change, and `flows` is the one that is `planned`. ⚠️ This sentence used to read \"10 of 11 groups live; the one dead group (`validationMessages`) is pointed at by #3778's own legacy-key migration table, making it a shipped false signpost\" — describing a group REMOVED in 17.0.0 (#4667), i.e. prose outliving its subject in the header of the very file whose rows warn about that. Corrected 2026-09-02 (#14253). Seeded 2026-08-01 (#4488). 2026-08-28 (#13003): all nine `path:NNN` citations in this file were re-anchored to their consuming symbols; EIGHT of the nine were wrong and every one of those was IN RANGE (the exception is `locale`, whose range still lands inside its reader). This ledger carried the batch's heaviest load of the OTHER silent class as well — nine further positions written as bare `:NNN` suffixes with no path in front of them, which `PATH_RE` never matches, so they degraded to prose that no check has ever resolved, bounded or key-checked.", "props": { "name": { "status": "live", @@ -54,6 +54,38 @@ "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupDashboardAttr (`dashboards..label` / `.description`); packages/spec/src/system/i18n-resolver.ts#lookupWidgetAttr (`dashboards..widgets..`, `subCaption` included); packages/spec/src/system/i18n-resolver.ts#translateDashboard", "note": "translateDashboard: label/description plus per-widget title/description/subCaption by widget id; header action labels. `subCaption` (#7862, #5428 item 4) overlays the metric widget's `options.description` — a different authored field from `widget.description`, each on its own key — live through the same translateDashboard REST path; objectui's client-side renderer half is the downstream follow-up. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `:538` had rotted onto the opening line of `translateAction`'s docblock, an ACTION resolver ~244 lines above the dashboard ones; the second position `:554` was a bare line suffix with no path and resolved to nothing. Re-closed by hand against 8cb96ec41." }, + "datasets": { + "status": "live", + "verifiedAt": "2026-09-02", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupDatasetAttr (`datasets..label` / `.description`); packages/spec/src/system/i18n-resolver.ts#lookupDatasetMemberLabel (`datasets..dimensions..label` and `.measures..label`); packages/spec/src/system/i18n-resolver.ts#translateDataset (registered in METADATA_DOCUMENT_TRANSLATORS, which is what TRANSLATABLE_METADATA_TYPES is derived from, so packages/rest/src/rest-server.ts#translateMetaItem localizes a served dataset with nothing else to remember, #3786)", + "note": "Seeded 2026-09-02 (#14253) LIVE, with its reader in the same change — not declared first and wired later. A dataset reads like a back-office definition, but a measure label is drawn ON THE DASHBOARD, under every metric tile and on every chart axis; before this `dataset` was neither in TRANSLATABLE_METADATA_TYPES nor addressed by any group, so a translated dashboard rendered Chinese tile titles with `Untouched > 14 days` directly beneath them. Top-level rather than nested under `dashboards` because a dataset is the ONE definition every presentation binds to by reference (ADR-0021 D1): the same measure is drawn by N widgets across M dashboards, and a dataset no dashboard references would otherwise be unaddressable. WALK BOUNDARY as for every other group here — the drill sees this record's VALUE shape one level; the per-key conventions below it (dimensions..label, measures..label) are governed by the resolvers cited above. Key face measured against DatasetSchema: a dimension and a measure each declare `label` and nothing else display-shaped, which `dataset.zod.ts` states at the authoring site too ('its author-facing text is `label`. `description` is declared on the DATASET itself'), so `description` lives on the dataset and nowhere below it.", + "children": { + "label": { + "status": "live", + "verifiedAt": "2026-09-02", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupDatasetAttr (`datasets..label`); packages/spec/src/system/i18n-resolver.ts#translateDataset (writes it onto the served document)", + "note": "the dataset's own display name. Written only when the bundle answers, so an inline I18nLabel locale map the bundle does not cover is left intact (#5728)." + }, + "description": { + "status": "live", + "verifiedAt": "2026-09-02", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupDatasetAttr (`datasets..description`); packages/spec/src/system/i18n-resolver.ts#translateDataset", + "note": "the dataset's explanatory line. It is the ONLY description on this surface: `DatasetDimensionSchema` / `DatasetMeasureSchema` declare none and say so in their own authoring guidance, which is why the translation face has none below this level either." + }, + "dimensions": { + "status": "live", + "verifiedAt": "2026-09-02", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupDatasetMemberLabel (`datasets..dimensions..label`); packages/spec/src/system/i18n-resolver.ts#translateDatasetMembers (overlays each dimension, keyed by `DatasetDimensionSchema.name`)", + "note": "one key beneath this record — `label`, the string drawn on chart axes and group headers. Keyed by the dimension's declared `name`, which is also how every presentation binds to it (ADR-0021)." + }, + "measures": { + "status": "live", + "verifiedAt": "2026-09-02", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupDatasetMemberLabel (`datasets..measures..label`); packages/spec/src/system/i18n-resolver.ts#translateDatasetMembers (overlays each measure, keyed by `DatasetMeasureSchema.name`)", + "note": "one key beneath this record — `label`, the string drawn under every metric tile and on chart axes. This is the key #14253 was filed on: a translated dashboard rendered Chinese tile titles with `Untouched > 14 days` directly beneath them." + } + } + }, "pages": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 246e4d543b..5c7c666821 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { ObjectTranslationDataSchema, TranslationDataSchema, type TranslationBundle } from './translation.zod'; +import { ObjectTranslationDataSchema, TranslationDataSchema, TranslationItemSchema, type TranslationBundle } from './translation.zod'; import { GetFieldLabelsResponseSchema, GetLocalesResponseSchema } from '../api/protocol.zod'; import { resolveViewLabel, @@ -16,7 +16,12 @@ import { toLocaleDescriptors, normalizeSupportedLocales, objectValidationMessageKey, + TRANSLATABLE_METADATA_TYPES, } from './i18n-resolver'; +// #14253 — the dataset fixture is PARSED by the authoring schema rather than +// hand-shaped, so a translator addressing a member name the schema does not +// carry cannot pass here. +import { DatasetSchema } from '../ui/dataset.zod'; // #4854 — the served view document is whatever THIS composer emits, so the // fixture below is generated by it rather than transcribed from a bug report. import { expandViewContainer } from '../ui/view.zod'; @@ -3040,3 +3045,172 @@ describe('ObjectTranslationDataSchema — _validations (#14253)', () => { })).toThrow(/CEL predicate, not display copy/); }); }); + +/** + * Dataset labels (#14253, surface 3). + * + * A dataset reads like a back-office definition, but a measure label is drawn + * ON THE DASHBOARD — under every metric tile and on every chart axis. Measured + * on a translated dashboard the tile titles were Chinese (those are + * `dashboards..widgets..*`) and directly beneath each one the measure + * label rendered `Untouched > 14 days`. + * + * The fixture is parsed by `DatasetSchema`, not hand-shaped: a translator that + * addresses members by a name the authoring schema does not carry is + * unresolvable, which is the trap the whole file is built to keep out. + */ +describe('translateDataset (#14253)', () => { + const dataset = () => DatasetSchema.parse({ + name: 'duty_pulse', + label: 'Duty pulse', + description: 'Outstanding duties by age', + object: 'duly_duty', + dimensions: [ + { name: 'owner', field: 'owner_id', label: 'Owner' }, + // No bundle entry — the negative control. + { name: 'status', field: 'status', label: 'Status' }, + ], + measures: [ + { name: 'untouched_14d', aggregate: 'count', label: 'Untouched > 14 days' }, + { name: 'oldest_touch', aggregate: 'min', field: 'updated_at', label: 'Oldest touch' }, + ], + }) as any; + + const bundle: TranslationBundle = { + 'zh-CN': { + datasets: { + duty_pulse: { + label: '任务脉搏', + description: '按停滞时长统计的未完成任务', + dimensions: { owner: { label: '负责人' } }, + measures: { + untouched_14d: { label: '停滞超过 14 天' }, + oldest_touch: { label: '最久未更新' }, + }, + }, + }, + }, + }; + + it('is registered in the dispatch table, so the REST boundary follows (#3786)', () => { + // `TRANSLATABLE_METADATA_TYPES` is DERIVED from `METADATA_DOCUMENT_TRANSLATORS` + // and `@objectstack/rest` reads the derived set — this is the assertion that + // the wiring is one edit, not two. + expect(TRANSLATABLE_METADATA_TYPES.has('dataset')).toBe(true); + }); + + it('translates the dataset label and description', () => { + const out = translateMetadataDocument('dataset', dataset(), bundle, { locale: 'zh-CN' }) as any; + expect(out.label).toBe('任务脉搏'); + expect(out.description).toBe('按停滞时长统计的未完成任务'); + // Identity and the semantic contract ride through untouched. + expect(out.name).toBe('duty_pulse'); + expect(out.object).toBe('duly_duty'); + }); + + it('translates measure labels — the strings drawn under every metric tile', () => { + const out = translateMetadataDocument('dataset', dataset(), bundle, { locale: 'zh-CN' }) as any; + expect(out.measures.map((m: any) => m.label)).toEqual(['停滞超过 14 天', '最久未更新']); + // The aggregation and field are semantic, not copy. + expect(out.measures[1]).toMatchObject({ name: 'oldest_touch', aggregate: 'min', field: 'updated_at' }); + }); + + it('translates dimension labels and leaves an unaddressed one as authored', () => { + const out = translateMetadataDocument('dataset', dataset(), bundle, { locale: 'zh-CN' }) as any; + expect(out.dimensions.find((d: any) => d.name === 'owner').label).toBe('负责人'); + expect(out.dimensions.find((d: any) => d.name === 'status').label).toBe('Status'); + }); + + it('leaves an inline locale map intact when the bundle does not cover it (#5728)', () => { + // `label` is `I18nLabelSchema`, so the author may already have written a + // map. Resolving over it would flatten four languages down to one. + const inline = DatasetSchema.parse({ + name: 'duty_pulse', + label: { en: 'Duty pulse', 'ja-JP': 'デューティ・パルス' }, + object: 'duly_duty', + dimensions: [{ name: 'owner', field: 'owner_id', label: { en: 'Owner', 'ja-JP': '担当者' } }], + measures: [{ name: 'untouched_14d', aggregate: 'count', label: { en: 'Untouched', 'ja-JP': '未着手' } }], + }) as any; + const out = translateMetadataDocument('dataset', inline, { 'ja-JP': {} }, { locale: 'ja-JP' }) as any; + expect(out.label).toEqual({ en: 'Duty pulse', 'ja-JP': 'デューティ・パルス' }); + expect(out.dimensions[0].label).toEqual({ en: 'Owner', 'ja-JP': '担当者' }); + expect(out.measures[0].label).toEqual({ en: 'Untouched', 'ja-JP': '未着手' }); + }); + + it('does not mutate the input, and keeps array identity when nothing matched', () => { + const doc = dataset(); + const before = JSON.parse(JSON.stringify(doc)); + const untouched = translateMetadataDocument('dataset', doc, { 'ja-JP': {} }, { locale: 'ja-JP' }) as any; + expect(doc).toEqual(before); + expect(untouched.measures).toBe(doc.measures); + expect(untouched.dimensions).toBe(doc.dimensions); + + translateMetadataDocument('dataset', doc, bundle, { locale: 'zh-CN' }); + expect(doc).toEqual(before); + }); + + it('applies the BCP-47 ladder and the fallback chain', () => { + expect((translateMetadataDocument('dataset', dataset(), bundle, { locale: 'zh' }) as any).label) + .toBe('任务脉搏'); + expect((translateMetadataDocument('dataset', dataset(), bundle, { + locale: 'fr-FR', fallbackChain: ['zh-CN'], + }) as any).measures[0].label).toBe('停滞超过 14 天'); + }); + + it('returns the document unchanged with no bundle and with no name', () => { + const doc = dataset(); + expect(translateMetadataDocument('dataset', doc, undefined, { locale: 'zh-CN' })).toBe(doc); + const anonymous = { label: 'x' }; + expect(translateMetadataDocument('dataset', anonymous, bundle, { locale: 'zh-CN' })).toBe(anonymous); + }); +}); + +describe('TranslationDataSchema — datasets (#14253)', () => { + it('accepts the measured key face', () => { + const data = TranslationDataSchema.parse({ + datasets: { + duty_pulse: { + label: '任务脉搏', + description: '说明', + dimensions: { owner: { label: '负责人' } }, + measures: { untouched_14d: { label: '停滞超过 14 天' } }, + }, + }, + }); + expect(data.datasets?.duty_pulse.measures?.untouched_14d.label).toBe('停滞超过 14 天'); + }); + + it('renames the singular spelling and the Cube vocabulary onto the group', () => { + expect(() => TranslationDataSchema.parse({ dataset: { duty_pulse: { label: 'x' } } })) + .toThrow(/dataset[\s\S]*datasets/); + expect(() => TranslationDataSchema.parse({ datasets: { duty_pulse: { metrics: { m: { label: 'x' } } } } })) + .toThrow(/metrics[\s\S]*measures/); + }); + + it('refuses `description` on a dimension or a measure, with the reason', () => { + for (const group of ['dimensions', 'measures']) { + let msg = ''; + try { + TranslationDataSchema.parse({ + datasets: { duty_pulse: { [group]: { m: { description: '说明' } } } }, + }); + } catch (e) { msg = String(e); } + expect(msg).toMatch(/its author-facing text is `label`/); + expect(msg).toMatch(/datasets\.\.description/); + } + }); + + it('refuses the machine keys an author might copy off the dataset', () => { + expect(() => TranslationDataSchema.parse({ datasets: { d: { object: 'duly_duty' } } })) + .toThrow(/base object name, not display copy/); + expect(() => TranslationDataSchema.parse({ datasets: { d: { format: '$0,0.00' } } })) + .toThrow(/number format string/); + }); + + it('closes at BOTH doors — bundle entry and registered item', () => { + const payload = { datasets: { d: { measures: { m: { description: 'x' } } } } }; + expect(() => TranslationDataSchema.parse(payload)).toThrow(/its author-facing text is `label`/); + expect(() => TranslationItemSchema.parse({ locale: 'zh-CN', ...payload })) + .toThrow(/its author-facing text is `label`/); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index a7b16debc2..6ec50a5a83 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -754,6 +754,11 @@ const METADATA_DOCUMENT_TRANSLATORS: Record< object: translateObject, app: translateApp, dashboard: translateDashboard, + // A dataset's measure labels are drawn on the dashboard, under every metric + // tile and on every chart axis (#14253). Registering it HERE is the whole + // wiring — `TRANSLATABLE_METADATA_TYPES` is derived below, and + // `@objectstack/rest` reads the derived set. + dataset: translateDataset, page: translatePage, }; @@ -989,6 +994,137 @@ export function translateDashboard( }; } +// ──────────────────────────────────────────────────────────────────────────── +// Dataset metadata resolver (label / description / dimension + measure labels) +// ──────────────────────────────────────────────────────────────────────────── + +/** Minimal dimension/measure shape consumed by {@link translateDataset}. */ +export interface DatasetMemberLike { + /** `DatasetDimensionSchema.name` / `DatasetMeasureSchema.name`. */ + name?: string; + /** `I18nLabelSchema` — a plain string OR an inline locale map (#5728). */ + label?: unknown; + [key: string]: unknown; +} + +/** Minimal dataset metadata shape consumed by {@link translateDataset}. */ +export interface DatasetLike { + name: string; + label?: unknown; + description?: unknown; + dimensions?: DatasetMemberLike[]; + measures?: DatasetMemberLike[]; + [key: string]: any; +} + +function lookupDatasetAttr( + bundle: TranslationBundle | undefined, + name: string, + attr: 'label' | 'description', + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.datasets?.[name]?.[attr]; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +function lookupDatasetMemberLabel( + bundle: TranslationBundle | undefined, + datasetName: string, + group: 'dimensions' | 'measures', + memberName: string, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = + pickData(bundle, code)?.datasets?.[datasetName]?.[group]?.[memberName]?.label; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * Overlay the bundle's labels onto a dataset's `dimensions[]` / `measures[]`, + * returning the SAME array reference when nothing matched. + */ +function translateDatasetMembers( + members: unknown, + bundle: TranslationBundle | undefined, + datasetName: string, + group: 'dimensions' | 'measures', + opts?: ResolveOptions, +): unknown { + if (!Array.isArray(members)) return members; + let changed = false; + const next = members.map((raw) => { + const member = raw as DatasetMemberLike; + if (!member || typeof member !== 'object' || typeof member.name !== 'string' || member.name.length === 0) { + return raw; + } + const label = lookupDatasetMemberLabel(bundle, datasetName, group, member.name, opts); + if (label === undefined) return raw; + changed = true; + return { ...member, label }; + }); + return changed ? next : members; +} + +/** + * Apply the active locale to a dataset metadata document — translates the + * dataset's `label` / `description` and each dimension's and measure's `label` + * against `datasets..{dimensions,measures}..label`. The input + * document is not mutated. + * + * ## Why a dataset needs this at all + * + * A dataset reads like a back-office definition, but a measure label is drawn + * ON THE DASHBOARD — under every metric tile and on every chart axis. Before + * this, `dataset` was neither in {@link TRANSLATABLE_METADATA_TYPES} nor + * addressed by any bundle group, so a fully translated dashboard rendered + * Chinese tile titles with `Untouched > 14 days` directly beneath them + * (#14253). + * + * Registering the translator in {@link METADATA_DOCUMENT_TRANSLATORS} is the + * whole wiring: `TRANSLATABLE_METADATA_TYPES` is DERIVED from that table, and + * `@objectstack/rest` reads the derived set, so the REST boundary follows with + * nothing else to remember (#3786). + * + * ## Writes only where the bundle answered + * + * `Dataset.label` / `.description` and the member labels are `I18nLabelSchema`, + * so an author may already have written an inline `{ en, 'zh-CN' }` map (#5728). + * Overwriting one with a resolved string would flatten a four-language label + * down to one, so — exactly as `translatePage` does — a key the bundle does not + * carry leaves the authored value untouched, and the arrays keep their identity + * when nothing matched. + */ +export function translateDataset( + doc: T, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): T { + if (!doc || typeof doc !== 'object') return doc; + const name = doc.name; + if (!name || !bundle) return doc; + + const label = lookupDatasetAttr(bundle, name, 'label', opts); + const description = lookupDatasetAttr(bundle, name, 'description', opts); + const dimensions = translateDatasetMembers(doc.dimensions, bundle, name, 'dimensions', opts); + const measures = translateDatasetMembers(doc.measures, bundle, name, 'measures', opts); + + return { + ...doc, + ...(label !== undefined ? { label } : {}), + ...(description !== undefined ? { description } : {}), + ...(dimensions !== doc.dimensions ? { dimensions } : {}), + ...(measures !== doc.measures ? { measures } : {}), + }; +} + // ──────────────────────────────────────────────────────────────────────────── // Page metadata resolvers (label / description / page:header title + subtitle) // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 29a3edfb3b..fc853c4d01 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -606,6 +606,23 @@ const FLOW_SCREEN_FIELD_NO_HELP = + '`name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen`, so there is ' + 'nothing here to translate. Use `placeholder` for the in-input hint the field does declare.'; +/** + * The measured exclusion on `datasets..dimensions.` and + * `.measures.`. + * + * Not inherited by symmetry from the dataset's own `description`: the authoring + * schema states it directly. `DatasetDimensionSchema` and `DatasetMeasureSchema` + * each carry a `guidance` entry for `description` reading "its author-facing + * text is `label`. `description` is declared on the DATASET itself" — so a key + * here would parse clean and translate nothing, the ADR-0078 shape this file + * keeps paying to remove. + */ +const DATASET_MEMBER_NO_DESCRIPTION = + 'a dataset dimension/measure has no `description` — its author-facing text is `label`, and ' + + '`DatasetDimensionSchema` / `DatasetMeasureSchema` say so at the authoring site too. ' + + '`description` is declared on the DATASET itself: translate it at ' + + "'datasets..description'."; + const FLOW_SCREEN_FIELD_NO_OPTIONS = 'select-option labels are not translatable on a screen field: `ScreenFieldConfig.options[].value` is ' + 'unconstrained (numbers and booleans are legal), so an option map keyed by value — the shape ' @@ -710,6 +727,84 @@ const translationDataShape = () => ({ })).optional().describe('Widget translations keyed by widget id'), })).optional().describe('Dashboard translations keyed by dashboard name'), + /** + * Analytics dataset translations keyed by dataset name (`Dataset.name`). + * + * Convention (auto-resolved by `translateDataset`): + * datasets..label + * datasets..description + * datasets..dimensions..label + * datasets..measures..label + * + * **The hole this closes (#14253).** A dataset reads like a back-office + * definition, but a measure label is drawn ON THE DASHBOARD — under every + * metric tile and on every chart axis — and `dataset` was neither in + * `TRANSLATABLE_METADATA_TYPES` nor addressed by any group. Measured on a + * translated dashboard: the tile titles and descriptions were Chinese (those + * are `dashboards..widgets..*`) and directly beneath each one the + * measure label rendered `Untouched > 14 days` / `Oldest touch`. Not a + * drifted key: no key. + * + * **Why a top-level group and not `dashboards..…`.** A dataset is the + * ONE semantic definition every presentation binds to by reference + * (ADR-0021 D1) — the same measure is drawn by N widgets across M dashboards. + * Addressing it under a dashboard would ask the translator to write the same + * string once per presentation and would leave a dataset that no dashboard + * references (a report's, or an API consumer's) unaddressable. One + * definition, one key. + * + * **The key face is measured against `DatasetSchema`.** A dimension and a + * measure each declare exactly one piece of display copy — `label` — and + * `dataset.zod.ts` says so twice, in the `guidance` it already gives an + * author who writes `description` on either: *"its author-facing text is + * `label`. `description` is declared on the DATASET itself"*. So + * `description` is declared here on the dataset and NOWHERE below it; a + * `dimensions..description` key would parse clean and translate nothing. + * + * ⚠️ These four are `I18nLabelSchema` at the authoring site, so a dataset's + * copy may already be an inline `{ en, 'zh-CN' }` map (#5728). + * `translateDataset` writes ONLY where the bundle actually has an entry, so + * an inline map the bundle does not cover is left intact rather than + * flattened to one language — the same rule `translatePage` follows. + */ + datasets: z.record(z.string(), strictObject({ + surface: 'this dataset translation', + history: TRANSLATION_HISTORY, + aliases: { + name: 'label', title: 'label', displayName: 'label', + dimension: 'dimensions', axes: 'dimensions', groupBy: 'dimensions', + measure: 'measures', metrics: 'measures', metric: 'measures', values: 'measures', + }, + guidance: { + // The dataset layer is deliberately smaller than a query, and none of + // these is display copy — pointing an author at `label` would translate + // the wrong thing. + object: '`object` is the dataset\'s base object name, not display copy — the object\'s own label is translated at \'objects..label\'.', + include: '`include` lists relationship names/paths the joins are compiled from — machine names, identical in every locale.', + filter: '`filter` is the dataset\'s intrinsic scope predicate, not display copy.', + format: '`format` is a number format string (e.g. "$0,0.00"), not display copy. For a locale-correct currency symbol declare `currency` (ISO 4217) on the measure — presentations render it through `Intl`.', + }, + }, { + label: z.string().optional().describe('Translated dataset label'), + description: z.string().optional().describe('Translated dataset description'), + dimensions: z.record(z.string(), strictObject({ + surface: 'this dataset dimension translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', displayName: 'label', text: 'label' }, + guidance: { description: DATASET_MEMBER_NO_DESCRIPTION }, + }, { + label: z.string().optional().describe('Translated dimension label (drawn on chart axes and group headers)'), + })).optional().describe('Dimension translations keyed by dimension name (`DatasetDimensionSchema.name`)'), + measures: z.record(z.string(), strictObject({ + surface: 'this dataset measure translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', displayName: 'label', text: 'label' }, + guidance: { description: DATASET_MEMBER_NO_DESCRIPTION }, + }, { + label: z.string().optional().describe('Translated measure label (drawn under every metric tile and on chart axes)'), + })).optional().describe('Measure translations keyed by measure name (`DatasetMeasureSchema.name`)'), + })).optional().describe('Analytics dataset translations keyed by dataset name'), + /** * Page translations keyed by page name (`Page.name`). * @@ -1106,7 +1201,7 @@ 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', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + 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' }, // `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. @@ -1232,7 +1327,7 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ surface: 'this translation', history: TRANSLATION_HISTORY, guidance: TRANSLATION_KEY_GUIDANCE, - aliases: { object: 'objects', app: 'apps', page: 'pages', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, + 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(), locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), From eb7c464f0a3097173089dab38267262f06cec746 Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 2 Sep 2026 02:14:21 +0000 Subject: [PATCH 4/6] fix(spec): keep the retired-key guidance free of internal issue ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:doc-authoring` caught two `#NNNN` citations in the tombstone text this branch rewrote. That text is printed AT the author, verbatim, the moment their bundle is refused — by `os validate`, by a publish gate, by a parse — and none of those readers has a tracker, so the token resolves to nothing in the one place the sentence most needs to be actionable. Maintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成 经验,保留 issue id没有意义」. The customer-resolvable references stay: the protocol version, ADR-0049, the migration command and the replacement key path. Adds the negative pin the gate's own remedy asks for, beside the twin that pins the wording: the tombstone must name the live replacement group AND must not carry an issue id. Part of #14253 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- packages/spec/src/system/translation.test.ts | 18 ++++++++++++++++++ packages/spec/src/system/translation.zod.ts | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index c7e74b6b88..15783ab646 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1506,6 +1506,24 @@ describe('retired translation.validationMessages (#4667)', () => { .toThrow(/object\.validations\[\]\.message/s); }); + /** + * The tombstone stopped at "author it on the rule" while the rule's own text + * had no translation route at all — true when written, and the reason a + * `zh-CN` deployment read author-written refusals in English. There is a + * route now, and a tombstone that does not name it sends an author to a dead + * end a second time. + */ + it('names the live replacement group as well as the authoring site', () => { + for (const payload of [{ validationMessages: { x: 'y' } }, { errors: { x: 'y' } }]) { + let msg = ''; + try { TranslationDataSchema.parse(payload); } catch (e) { msg = String(e); } + expect(msg).toMatch(/_validations\.\.message/s); + // …and stays free of internal issue ids: this text is printed AT the + // author, verbatim, where `#NNNN` resolves to nothing (check:doc-authoring). + expect(msg).not.toMatch(/#\d{3,}/); + } + }); + it('the retired `errors` dialect no longer signposts a dead key', () => { // #3778 retired `errors` by telling authors to use `validationMessages` — // a signpost into another unread group. Taking that advice moved content diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index fc853c4d01..738d284dc2 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -535,7 +535,7 @@ const TRANSLATION_KEY_GUIDANCE: Record._validations..message' (#14253), which the write " + + "group 'objects.._validations..message', which the write " + 'path resolves. Delete this key. Run ' + '`os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', o: "`o` is the retired object-first dialect, which no resolver reads — use 'objects.'", @@ -551,7 +551,7 @@ const TRANSLATION_KEY_GUIDANCE: Record._validations..message' (#14253); omit `errors`.", + + "'objects.._validations..message'; omit `errors`.", _globalOptions: "`_globalOptions` is the retired object-first dialect — use 'objects..fields..options'", _meta: "`_meta` is the retired object-first dialect — use the top-level 'locale' field (on a bundle, the locale is the map key)", namespace: '`namespace` is not part of the translation contract — omit it (ADR-0129 D3 retired the separate namespace declaration platform-wide)', From b5bc09fb90d1d8338b22954dbfb3298452a00cca Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 2 Sep 2026 02:14:33 +0000 Subject: [PATCH 5/6] feat(rest): localize a saved dataset at the analytics query door too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface 3's second door, found by measuring where a measure label actually reaches a dashboard rather than assuming it rides the metadata read. `AnalyticsResult.fields[].label` is documented as the display label "for legends/KPIs", and `AnalyticsService` fills it by copying `dataset.measures[].label` off the definition. That definition arrives through `POST /analytics/dataset/query`, which resolves a saved dataset via `getMetaItems` and never passes through `translateMetaItem`. So `translateDataset` alone closes `/meta/datasets` — the door a dashboard does NOT draw through — and leaves the one in the issue's screenshot open: covered at one door, open at the other. Fixed at the metadata boundary, where every other document is localized: the resolved saved definition goes through the existing `translateMetaItem` before it is parsed and compiled, and the existing field enrichment carries the translated label to the wire untouched. Nothing downstream learns about bundles — the analytics service stays free of i18n and there is no second resolution path. ⛔ The INLINE branch is deliberately not translated: a Studio preview posts the draft the designer is editing, which carries no saved name to address a bundle entry with, and overwriting its copy would misreport what is about to be saved. Pinned by test, both ways. Part of #14253 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- .../i18n-three-authored-display-surfaces.md | 74 ++++++++++++++++++ packages/rest/src/analytics-routes.test.ts | 78 ++++++++++++++++++- packages/rest/src/rest-server.ts | 24 ++++++ 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 .changeset/i18n-three-authored-display-surfaces.md diff --git a/.changeset/i18n-three-authored-display-surfaces.md b/.changeset/i18n-three-authored-display-surfaces.md new file mode 100644 index 0000000000..1912308793 --- /dev/null +++ b/.changeset/i18n-three-authored-display-surfaces.md @@ -0,0 +1,74 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +--- + +feat(spec,objectql): give three authored display surfaces a bundle key — bulk-action defs, custom validation messages, dataset labels (#14253) + +Purely additive: three new translation groups, one new dispatch-table entry, one +new resolution step on the write path. No existing key changes shape, no +resolution order changes, and every surface still falls back to the authored +literal when the bundle carries nothing. + +Each of the three carried **authored, user-facing display text that no key in +`TranslationDataSchema` could reach** — not a drifted key, no key. Each rendered +in the source locale inside an otherwise fully translated screen, which is the +bad failure mode: it reads as a styling quirk rather than as a missing +translation. Measured on a real `zh-CN` deployment. + +**1. A list view's `bulkActionDefs[]`** — +`objects.._views..bulkActions..{label,confirmText,confirmLabel,params.

.{label,help,placeholder}}`, +resolved in `translateView` against `config.bulkActionDefs` (the one address a +served def has: both `ViewItemSchema` and `expandViewContainer` nest the whole +ListView under `config`). A def is part of the *view* document, not an action +document, so it never reached `translateAction`; the selection bar read +`已选择 1 项 · Complete · Skip · 清除`. The def's `label` deliberately stays a +plain `z.string()` on the authoring side — the bar renders it as a React child, +so an inline locale map would be a blank cell rather than a parse error — and +overlaying at the metadata boundary keeps the wire value a plain string. The +documented workaround (`bulkActions: ['']`, promoting a declared action) +is not equivalent: it is N elevated per-record dispatches instead of one +data-plane `updateMany`. + +**2. A custom validation rule's `message`** — +`objects.._validations..message`, spelled by the new +`objectValidationMessageKey` and read on the write path by the rule evaluator. +⚠️ **This adds a key shape, not a channel**: the lookup runs on the *existing* +`i18nService` hook that has localized built-in field-catalog messages and field +labels since #3957. Before it, a deployment got platform-generated refusals in +the caller's language and author-written refusals in the source language inside +one `400 VALIDATION_FAILED` envelope. All five authored-message emitters route +through one seat; a nested `conditional` branch is addressed by the branch's own +name; a platform-generated rejection (an unevaluable predicate) is deliberately +left alone. `messages['validation.field.*']` is unchanged and still overrides the +built-in catalog only. + +**3. Dataset labels** — `datasets..{label,description,dimensions..label,measures..label}` +plus `translateDataset` in `METADATA_DOCUMENT_TRANSLATORS`. A dataset reads like +a back-office definition, but a measure label is drawn on the dashboard, under +every metric tile and on every chart axis. Registering the translator is the +whole wiring — `TRANSLATABLE_METADATA_TYPES` is derived from that table and +`@objectstack/rest` reads the derived set (#3786) — so `GET /api/v1/meta/datasets?locale=…` +localizes with nothing else to remember. + +Key faces are measured against the authoring schemas rather than mirrored from +the report, so nothing here parses clean and translates nothing: a bulk param's +hint is `help` (not the action-param `helpText`), per-param `options` are refused +because `options[].value` is unconstrained and a value-keyed map cannot address +`true` and `"true"` apart, a def has no `successMessage`, and a dataset dimension +or measure has no `description` — the authoring schema says so itself. Every +exclusion carries `guidance` naming the right home. + +Two tombstones stop asserting that no route exists: the retired +`validationMessages` and `errors` guidance now point at +`objects.._validations..message`. Retiring `validationMessages` +(17.0.0, #4667, ADR-0049) is **not** reversed — that group was keyed by rule name +at the bundle's top level, so it could not tell two objects' rules apart, and, +the reason it was retired, nothing read it. Its ADR-0087 conversion still strips +it from stored bundles. The replacement is object-scoped and ships its reader in +the same change. + +Authors upgrading need do nothing; a bundle that writes none of the three new +groups behaves exactly as before. + + diff --git a/packages/rest/src/analytics-routes.test.ts b/packages/rest/src/analytics-routes.test.ts index d533503f6e..9d0a4a44a9 100644 --- a/packages/rest/src/analytics-routes.test.ts +++ b/packages/rest/src/analytics-routes.test.ts @@ -41,7 +41,7 @@ function buildServer(analyticsProvider?: any, protocol: any = mockProtocol()) { (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); const route = rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/analytics/dataset/query')); - return { route }; + return { route, rest }; } describe('POST /analytics/dataset/query', () => { @@ -129,6 +129,82 @@ describe('POST /analytics/dataset/query', () => { expect(queryDataset.mock.calls[0][0]).not.toHaveProperty('_diagnostics'); }); + // ── the localization door (#14253) ───────────────────────────────────────── + // + // A dataset's dimension and measure labels are drawn on the DASHBOARD, and + // they reach it through THIS route, not through `/meta/datasets`: + // `AnalyticsService` copies `dataset.measures[].label` onto + // `AnalyticsResult.fields[].label` ("for legends/KPIs"). Translating only the + // metadata read would have closed the door nobody draws through. + // + // The bundle is served through the SAME `II18nService` shape the `/meta` + // routes read (`getLocales` + `getTranslations`), so this pins the wiring + // rather than a second resolution path. + const zhI18n = () => ({ + getLocales: () => ['zh-CN'], + getTranslations: (locale: string) => (locale === 'zh-CN' + ? { + datasets: { + sales: { + label: '销售', + dimensions: { region: { label: '区域' } }, + measures: { revenue: { label: '营业额' } }, + }, + }, + } + : {}), + }); + + it('localizes a SAVED dataset before it is compiled, so measure labels reach the chart translated', async () => { + const protocol = { ...mockProtocol(), getMetaItems: vi.fn().mockResolvedValue({ items: [{ ...inlineDataset }] }) }; + const queryDataset = vi.fn().mockResolvedValue({ rows: [], fields: [] }); + const { route, rest } = buildServer(async () => ({ queryDataset }), protocol); + (rest as any).resolveI18nService = async () => zhI18n(); + const res = mockRes(); + await route!.handler( + { method: 'POST', params: {}, headers: { 'accept-language': 'zh-CN' }, body: { datasetName: 'sales', selection } } as any, + res, + ); + + expect(res.statusCode).toBe(200); + const passed = queryDataset.mock.calls[0][0]; + expect(passed.label).toBe('销售'); + expect(passed.measures.find((m: any) => m.name === 'revenue').label).toBe('营业额'); + expect(passed.dimensions.find((d: any) => d.name === 'region').label).toBe('区域'); + // The semantic contract is untouched — only the copy moved. + expect(passed.object).toBe('opportunity'); + expect(passed.measures[0].aggregate).toBe('sum'); + }); + + it('leaves a saved dataset alone when the request names no locale', async () => { + const protocol = { ...mockProtocol(), getMetaItems: vi.fn().mockResolvedValue({ items: [{ ...inlineDataset }] }) }; + const queryDataset = vi.fn().mockResolvedValue({ rows: [], fields: [] }); + const { route, rest } = buildServer(async () => ({ queryDataset }), protocol); + (rest as any).resolveI18nService = async () => zhI18n(); + const res = mockRes(); + await route!.handler({ method: 'POST', params: {}, headers: {}, body: { datasetName: 'sales', selection } } as any, res); + + expect(res.statusCode).toBe(200); + expect(queryDataset.mock.calls[0][0].label).toBe('Sales'); + }); + + // ⛔ The INLINE branch is the Studio preview posting the draft the designer + // is editing: it carries no saved name to address a bundle entry with, and + // overwriting its copy would misreport what is about to be saved. + it('does NOT localize an INLINE draft, even when the bundle has an entry for that name', async () => { + const queryDataset = vi.fn().mockResolvedValue({ rows: [], fields: [] }); + const { route, rest } = buildServer(async () => ({ queryDataset })); + (rest as any).resolveI18nService = async () => zhI18n(); + const res = mockRes(); + await route!.handler( + { method: 'POST', params: {}, headers: { 'accept-language': 'zh-CN' }, body: { dataset: { ...inlineDataset }, selection } } as any, + res, + ); + + expect(res.statusCode).toBe(200); + expect(queryDataset.mock.calls[0][0].label).toBe('Sales'); + }); + it('returns 404 for an unknown datasetName', async () => { const protocol = { ...mockProtocol(), getMetaItems: vi.fn().mockResolvedValue({ items: [] }) }; const { route } = buildServer(async () => ({ queryDataset: vi.fn() }), protocol); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 81e8ced492..425aef40bc 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -9661,6 +9661,30 @@ export class RestServer { if (!dataset) { return res.status(404).json({ code: 'NOT_FOUND', message: `Dataset "${body.datasetName}" not found.` }); } + // [#14253] Localize the SAVED definition here, at the + // metadata boundary, the same way `/meta/:type` does. + // + // A dataset's dimension and measure `label`s are drawn + // on the dashboard — chart axes, legends, the caption + // under a metric tile — and they get there through the + // QUERY, not through `/meta/datasets`: + // `AnalyticsService` copies `dataset.measures[].label` + // onto `AnalyticsResult.fields[].label` ("for + // legends/KPIs"). So translating only the metadata read + // would have closed the door nobody draws through and + // left the one in the issue's screenshot open — the + // covered-at-one-door asymmetry this repo keeps paying + // for. Translating the DEFINITION carries through the + // existing enrichment untouched, so nothing downstream + // learns about bundles: the analytics service stays + // free of i18n, and there is no second resolution path. + // + // ⛔ The INLINE branch is deliberately not translated: + // a Studio preview posts a draft the designer is + // editing, which carries no saved name to address a + // bundle entry with, and overwriting a draft's copy + // would misreport what the designer is about to save. + dataset = await this.translateMetaItem(req, 'dataset', environmentId, dataset); } if (!dataset) { return res.status(400).json({ code: 'VALIDATION_FAILED', message: 'Provide body.dataset (inline) or body.datasetName.' }); From 7b1c387bb3769c44f03b6c8b79757d62d98960f2 Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 2 Sep 2026 04:19:39 +0000 Subject: [PATCH 6/6] fix(lint): give `_validations` a reference-checked leg in validate-translation-references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #14253 added `objects.._validations..message` to `ObjectTranslationDataSchema`; the lint rule's per-group coverage pin (`classifies every key the schema declares, and no key it does not`) correctly went red because the rule had no leg for it — the exact "second hand-maintained list drifted" class the PR exists to close. The walker now registers `objects[].validations[].name` per object and reports `translation-target-unknown` for a `_validations` key naming a rule the object does not declare, with the same guidance shape as `_tabs`. The coverage pin and the all-real-names control both carry the new group; the fixture declares one rule so the control stays non-vacuous. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- .../i18n-three-authored-display-surfaces.md | 1 + .../validate-translation-references.test.ts | 7 ++++ .../src/validate-translation-references.ts | 39 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/.changeset/i18n-three-authored-display-surfaces.md b/.changeset/i18n-three-authored-display-surfaces.md index 1912308793..3dd60bfc10 100644 --- a/.changeset/i18n-three-authored-display-surfaces.md +++ b/.changeset/i18n-three-authored-display-surfaces.md @@ -1,6 +1,7 @@ --- "@objectstack/spec": minor "@objectstack/objectql": minor +'@objectstack/lint': patch --- feat(spec,objectql): give three authored display surfaces a bundle key — bulk-action defs, custom validation messages, dataset labels (#14253) diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index c8c1ef6f0a..ca3d74496e 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -1448,6 +1448,7 @@ describe('validateTranslationReferences — object-branch coverage vs the schema fields: { name: { type: 'text', label: 'Name' } }, fieldGroups: [{ key: 'basics', label: 'Basics' }], actions: [{ name: 'convert_lead', label: 'Convert' }], + validations: [{ name: 'lead_needs_name', type: 'script', message: 'Name is required' }], }, ], views: [{ name: 'open_leads', objectName: 'crm_lead', label: 'Open Leads' }], @@ -1503,6 +1504,11 @@ describe('validateTranslationReferences — object-branch coverage vs the schema ghost: { _tabs: { ghost_tab: { label: 'Ghost' } } }, path: 'translations[0].en.objects.crm_lead._tabs.ghost_tab', }, + _validations: { + kind: 'reference-checked', + ghost: { _validations: { ghost_rule: { message: 'Ghost' } } }, + path: 'translations[0].en.objects.crm_lead._validations.ghost_rule', + }, }; it('classifies every key `ObjectTranslationDataSchema` declares, and no key it does not', () => { @@ -1537,6 +1543,7 @@ describe('validateTranslationReferences — object-branch coverage vs the schema _sections: { basics: { label: 'Basics' } }, _actions: { convert_lead: { label: 'Convert' } }, _tabs: { urgent: { label: 'Urgent' } }, + _validations: { lead_needs_name: { message: 'A name is required' } }, }), ); expect(findings).toEqual([]); diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 3b7c91b24b..7f3776ab36 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -231,6 +231,13 @@ interface ObjectFacts { views: Set; actions: Map; sections: Set; + /** + * `_validations` names — the custom validation rules this object declares + * (`objects[].validations[].name`). `objects.._validations..message` + * (#14253) is keyed by that name, so a ghost here is a rule message that + * renders in the source locale inside an otherwise translated refusal. + */ + validations: Set; /** * `_tabs` names — the filter-preset tabs declared for this object, from * `page.interfaceConfig.userFilters.tabs[].name`. See {@link collectPageTabs} @@ -283,6 +290,7 @@ function emptyFacts(): ObjectFacts { views: new Set(), actions: new Map(), sections: new Set(), + validations: new Set(), tabs: new Set(), }; } @@ -618,6 +626,13 @@ function buildUniverse(stack: AnyRec): Universe { const key = strName(group.key) ?? strName(group.name); if (key) facts.sections.add(key); } + // #14253: `_validations.` is keyed by the rule's own `name`. A rule + // without a name has no key and is not registered — the resolver cannot + // address it either, so nothing is lost by skipping it here. + for (const rule of asArray(obj.validations)) { + const ruleName = strName(rule.name); + if (ruleName) facts.validations.add(ruleName); + } } // ── Stack-level views: `_views` names + form-section names ── @@ -900,6 +915,30 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind ); } + // _validations. + // + // Same shape as `_tabs` above: a machine name that either exists on + // this object or does not. An orphan here is worse than an untranslated + // label — the refusal a caller receives keeps its source-locale + // `message` beside platform-generated refusals in the caller's locale, + // inside one `VALIDATION_FAILED` envelope (#14253). + for (const ruleName of Object.keys(asRecord(rawNode._validations))) { + if (facts.validations.has(ruleName)) continue; + orphan( + `${inLocale} · object "${objectName}" · validation "${ruleName}"`, + `${objPath}._validations.${ruleName}`, + `Translations are keyed to validation rule "${ruleName}", which object ` + + `"${objectName}" does not declare in \`validations[]\`. The rule's authored ` + + `\`message\` keeps its source locale in every refusal.` + + suggest(ruleName, facts.validations), + `Match the key to the rule's \`name\`, or drop it if the rule was renamed or ` + + `removed.` + + (facts.validations.size > 0 + ? ` Declared rules: ${listNames(facts.validations)}.` + : ` Object "${objectName}" declares no named validation rules.`), + ); + } + // _actions.[.params.] for (const [actionName, rawAction] of Object.entries(asRecord(rawNode._actions))) { const actionPath = `${objPath}._actions.${actionName}`;