From 411513f095f8d3afe5d826b9dee20ccc4dc51282 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:18:00 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(spec,core):=20item-door=20settings=20?= =?UTF-8?q?=E2=80=94=20D2=20item=20shape,=20semantic=20entry,=20stored-row?= =?UTF-8?q?=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of the item-door settings retirement: the D2 conversion translation-per-app-settings-removed learns the bare translation item shape (an entry carrying locale, or a top-level declared group for rows written before locale was required) and strips its top-level settings; the ADR-0087 semantic entry and the step-18 rationale are extended to the item door; and authored-translation-sync, which reads sys_metadata itself and merged the raw payload over the shipped bundles, now replays the conversion chain over each stored row before merging it, warning once per row per wiring. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- .../authored-translation-sync.test.ts | 120 ++++++++++++++++ .../fallbacks/authored-translation-sync.ts | 49 ++++++- packages/spec/src/conversions/registry.ts | 92 ++++++++---- ...nslation-per-app-settings-platform-only.ts | 120 +++++++++------- packages/spec/src/migrations/registry.ts | 133 +++++++++++------- 5 files changed, 387 insertions(+), 127 deletions(-) create mode 100644 packages/core/src/fallbacks/authored-translation-sync.test.ts diff --git a/packages/core/src/fallbacks/authored-translation-sync.test.ts b/packages/core/src/fallbacks/authored-translation-sync.test.ts new file mode 100644 index 00000000000..53c50e609b7 --- /dev/null +++ b/packages/core/src/fallbacks/authored-translation-sync.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #19620 — the stored-row half of retiring `settings` from the `translation` + * item door (ruling batch #210 item 2 letter B). + * + * Narrowing `TranslationItemSchema` refuses NEW items carrying `settings` at + * the metadata door, but it cannot reach a row already stored: this sync reads + * `sys_metadata` itself and used to merge the RAW payload into the authored + * layer, which both i18n adapters read OVER the shipped bundles. So a row + * written before the door closed kept overriding the platform's own Settings + * copy on every boot. The sync is a rehydration seam and now replays the + * ADR-0087 chain over each row before merging it, which is what drops the + * group — loudly, once per row. + * + * `@objectstack/spec` resolves to its BUILT `dist/` here (no alias), and the + * conversion that does the dropping lives there: rebuild spec before reading + * a result from this file. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { readAuthoredTranslationLayer, wireAuthoredTranslationSync } from './authored-translation-sync.js'; +import { createMemoryI18n } from './memory-i18n.js'; + +type AnyRecord = Record; + +const row = (name: string, payload: AnyRecord) => ({ + type: 'translation', + name, + state: 'active', + metadata: JSON.stringify(payload), +}); + +const engineOf = (rows: AnyRecord[]) => ({ + find: vi.fn(async (_object: string, q: AnyRecord) => (q?.where?.type === 'translation' ? rows : [])), +}); + +/** An item stored before the door closed: `settings` beside an app-owned group. */ +const storedWithSettings = () => row('zh-CN', { + locale: 'zh-CN', + settings: { mail: { title: '应用改写的邮件标题', keys: { host: { label: '应用改写的主机' } } } }, + apps: { crm: { label: '客户关系管理' } }, +}); + +describe('authored-translation sync replays the conversion chain over stored rows (#19620)', () => { + it('drops a stored item\'s `settings` before the merge and keeps the rest of the item', async () => { + const warn = vi.fn(); + const layer = await readAuthoredTranslationLayer(engineOf([storedWithSettings()]), { warn }); + + expect(layer).not.toBeNull(); + expect(layer!['zh-CN']).not.toHaveProperty('settings'); + // The app-owned group on the same row still loads — the row is converted, + // not skipped. + expect(layer!['zh-CN']).toEqual({ apps: { crm: { label: '客户关系管理' } } }); + }); + + it('says so, naming the row, the group and the conversion — never a silent strip', async () => { + const warn = vi.fn(); + await readAuthoredTranslationLayer(engineOf([storedWithSettings()]), { warn }); + + expect(warn).toHaveBeenCalledTimes(1); + const line = String(warn.mock.calls[0]?.[0]); + expect(line).toContain("authored translation 'zh-CN'"); + expect(line).toContain("'settings' → '(removed)'"); + expect(line).toContain("'translation-per-app-settings-removed'"); + expect(line).toContain('os migrate meta --stored --apply'); + }); + + it('warns once per row per wiring, not on every sync', async () => { + const warn = vi.fn(); + const warnedConversions = new Set(); + const engine = engineOf([storedWithSettings()]); + await readAuthoredTranslationLayer(engine, { warn }, { warnedConversions }); + await readAuthoredTranslationLayer(engine, { warn }, { warnedConversions }); + + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('CONTROL — a canonical row converts to itself and warns nothing', async () => { + const warn = vi.fn(); + const layer = await readAuthoredTranslationLayer( + engineOf([row('zh-CN', { locale: 'zh-CN', apps: { crm: { label: '客户关系管理' } } })]), + { warn }, + ); + + expect(layer!['zh-CN']).toEqual({ apps: { crm: { label: '客户关系管理' } } }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('end to end: the platform\'s own settings copy renders again, not the stored override', async () => { + // The platform bundle as `SettingsServicePlugin` contributes it — static. + const i18n = createMemoryI18n(); + i18n.loadTranslations('zh-CN', { + settings: { mail: { title: '邮件投递', keys: { host: { label: 'SMTP 主机' } } } }, + }); + + const hooks = new Map Promise | void>>(); + const warn = vi.fn(); + const services: AnyRecord = { i18n, objectql: engineOf([storedWithSettings()]) }; + wireAuthoredTranslationSync({ + logger: { warn, info: vi.fn(), debug: vi.fn() }, + getService: (name: string) => { + if (name in services) return services[name]; + throw new Error(`service '${name}' not registered`); + }, + hook: (name, fn) => hooks.set(name, [...(hooks.get(name) ?? []), fn]), + }); + for (const fn of hooks.get('kernel:ready') ?? []) await fn(); + for (const fn of hooks.get('metadata:reloaded') ?? []) await fn(); + + // Before the seam replayed the chain, the authored layer (read OVER the + // static bundle) answered these two with the stored row's strings. + expect(i18n.t('settings.mail.title', 'zh-CN')).toBe('邮件投递'); + expect(i18n.t('settings.mail.keys.host.label', 'zh-CN')).toBe('SMTP 主机'); + expect(i18n.t('apps.crm.label', 'zh-CN')).toBe('客户关系管理'); + // Two syncs, one warning: the wiring owns its dedupe set. + expect(warn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/fallbacks/authored-translation-sync.ts b/packages/core/src/fallbacks/authored-translation-sync.ts index 365ed4ee1bc..22cc679d7d1 100644 --- a/packages/core/src/fallbacks/authored-translation-sync.ts +++ b/packages/core/src/fallbacks/authored-translation-sync.ts @@ -41,8 +41,19 @@ * surface authored rows nowhere else, and the i18n map is process-wide so * rows are taken across all organizations. Best-effort: a failed read keeps * the currently applied authored layer. + * + * Reading the table directly makes this a stored-metadata REHYDRATION seam, + * so each row replays the full ADR-0087 conversion chain before it is merged + * (`applyConversionsToStoredItem`, retired entries included — the same policy + * as `protocol.loadMetaFromDb` and the objectql authored-hook re-sync). This + * layer is read OVER the shipped bundles, so a group a later protocol took + * off the item door would otherwise go on overriding them from a row written + * before the door closed: `settings`, the platform-only group, is the case + * that made the seam necessary (#19620). Each conversion is logged once per + * row per wiring, never per sync. */ +import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; import { LEGACY_OBJECT_FIRST_KEYS } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/spec/contracts'; @@ -85,6 +96,17 @@ interface AuthoredTranslationSink { // like `my_custom_strings` as locales. const LOCALE_LIKE = /^[a-z]{2,3}([_-]([A-Za-z]{4}|[A-Za-z]{2}|[0-9]{3}))?$/; +/** Options for {@link readAuthoredTranslationLayer}. */ +export interface ReadAuthoredTranslationLayerOptions { + /** + * Dedupe set for the stored-row conversion warning, keyed + * `|`. The sync re-reads every row on each publish, + * so a caller that syncs repeatedly passes one set for its lifetime and + * each legacy row warns once; omitted, every call warns. + */ + warnedConversions?: Set; +} + /** * Read ACTIVE `translation` metadata rows and compute the authored layer, * keyed by locale. Returns `null` when the read failed (callers must keep @@ -93,6 +115,7 @@ const LOCALE_LIKE = /^[a-z]{2,3}([_-]([A-Za-z]{4}|[A-Za-z]{2}|[0-9]{3}))?$/; export async function readAuthoredTranslationLayer( engine: { find(object: string, opts?: AnyRecord): Promise }, logger?: MinimalCtx['logger'], + options: ReadAuthoredTranslationLayerOptions = {}, ): Promise> | null> { let rows: any[]; try { @@ -137,6 +160,26 @@ export async function readAuthoredTranslationLayer( continue; } + // Stored-row rehydration (see the module doc): replay the full ADR-0087 + // chain so a group a later protocol took off the item door is dropped + // here, loudly, instead of overriding the shipped bundles from the raw row. + const rowName = String(row?.name ?? ''); + data = applyConversionsToStoredItem('translation', data as AnyRecord, { + onNotice: (n: ConversionNotice) => { + const key = `${n.conversionId}|${rowName}`; + if (options.warnedConversions?.has(key)) return; + options.warnedConversions?.add(key); + logger?.warn?.( + `[i18n] authored translation '${rowName}' carries a shape protocol ${n.toMajor} retired; ` + + `${n.message} That content is dropped before the merge and is not served: what renders ` + + 'at that path is what the shipped bundles carry, or the source literal where they carry ' + + 'nothing. The row itself is unchanged — re-save it (Studio edit → save) or run ' + + '"os migrate meta --stored --apply" to persist the canonical shape; ' + + `"os migrate meta --from ${n.toMajor - 1}" prints what the change means.`, + ); + }, + }); + const locale: string | undefined = (typeof data?.locale === 'string' && data.locale) || (typeof row?.name === 'string' && LOCALE_LIKE.test(row.name) ? row.name : undefined) @@ -186,6 +229,10 @@ export function wireAuthoredTranslationSync(ctx: MinimalCtx): void { return current === token ? i18n : null; // another wirer owns this instance }; + // One dedupe set per wiring: every sync re-reads every row, so a legacy row + // would otherwise re-warn on each publish. + const warnedConversions = new Set(); + // Serialized: overlapping publishes must not finish out of order and leave // the older authored snapshot applied. let chain: Promise = Promise.resolve(); @@ -196,7 +243,7 @@ export function wireAuthoredTranslationSync(ctx: MinimalCtx): void { let engine: IDataEngine | undefined; try { engine = ctx.getService('objectql'); } catch { return; } if (!engine || typeof engine.find !== 'function') return; - const layer = await readAuthoredTranslationLayer(engine, ctx.logger); + const layer = await readAuthoredTranslationLayer(engine, ctx.logger, { warnedConversions }); if (layer === null) return; // failed read — keep current layer i18n.replaceAuthoredTranslations(layer); ctx.logger.info?.('[i18n] synced runtime-authored translations', { diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 964d67e76ec..c8b9654cf96 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7114,9 +7114,10 @@ const elementFormRemoved: MetadataConversion = { }; /** - * `translation..settings` on a PER-APP bundle — the platform-only - * group leaving `stack.translations` with the type split (protocol 18, - * #15178, ruling batch #132 item 2 letter ②). + * `settings` on every APPLICATION-authored translation face — the platform-only + * group leaving `stack.translations` with the type split (protocol 18, #15178, + * ruling batch #132 item 2 letter ②) and then the registered `translation` + * item (#19620, ruling batch #210 item 2 letter B), both faces in one entry. * * ⛔ NOT a lossless delete, and this entry says so rather than claiming the * house phrase. `settings` is keyed by `SettingsManifest.namespace`, and only @@ -7140,30 +7141,45 @@ const elementFormRemoved: MetadataConversion = { * `18.translation-per-app-settings-platform-only.ts` is where an author is * told that, because a notice reading "(removed)" does not say it. * - * ⚠️ The BUNDLE shape only. `TranslationItemSchema` still declares `settings` - * (the registered `translation` metadata type is out of this ruling's scope), - * so a bare item entry replaying through this seam is left exactly as it is — - * the opposite of the `translation-component-submit-label-removed` neighbour, - * which retires its key at both doors and therefore walks both shapes. Getting - * this backwards would strip a key its own schema still accepts. - * - * The bundle is told from an item structurally rather than by key spelling: - * `locale` is REQUIRED on an item and never present on a bundle entry (the - * bundle's keys ARE the locales), and the candidate value must be a dict whose - * every key is a declared translation group — which an `objects` record, the - * one other dict-of-dicts at that depth, is not. + * The ITEM shape went further, and that is why it is walked too (#19620). A + * stored `translation` item is NOT a bundle loaded into the static tree: the + * runtime-authored layer is read OVER the shipped bundles + * (`deepMerge(static, authored)` in both i18n adapters), so an item's + * `settings` did override the platform's own copy for its locale, not merely + * fill gaps in it. Dropping it takes those keys back to the platform bundle's + * string where it has one and to the manifest literal where it does not. The + * seam that matters for an item is the runtime one: `authored-translation-sync` + * replays this chain over each stored row before merging it + * (`applyConversionsToStoredItem`), so a row written before the item door + * closed stops overriding at the next sync, with this entry's notice logged, + * rather than at the next re-save. + * + * Two shapes, told apart structurally rather than by key spelling: + * + * - **A bare item** carries `locale` (REQUIRED on an item since #3778), or — + * for a row written before `locale` was required, which the sync still reads + * by its name — has a declared translation GROUP as a top-level key. A + * bundle entry never does either: its top-level keys ARE the locale codes. + * Only the item's own top-level `settings` is stripped, so an object + * literally named `settings` under `objects` is untouched. + * - **A bundle entry**: the candidate value under each locale must be a dict + * whose every key is a declared translation group — which an `objects` + * record, the one other dict-of-dicts at that depth, is not. */ const translationPerAppSettingsRemoved: MetadataConversion = { id: 'translation-per-app-settings-removed', toMajor: 18, retiredFromLoadPath: true, - surface: 'stack.translations[]..settings', + surface: 'stack.translations[]..settings / translation.settings', summary: - "per-app translation group 'settings' removed (#15178 — it is keyed by SettingsManifest.namespace " - + 'and only platform code declares a manifest, so an app-authored entry could only fill gaps the ' - + "platform's own bundle left in the one merged served tree, and was overwritten wherever both " - + 'defined the key; those gaps now fall back to the manifest literal, and the group stays on the ' - + 'PLATFORM bundle, PlatformTranslationData)', + "translation group 'settings' removed from both application-authored faces, the per-app bundle " + + 'entry (#15178) and the registered translation item (#19620). It is keyed by ' + + 'SettingsManifest.namespace and only platform code declares a manifest. A per-app bundle entry ' + + "could only fill gaps the platform's own bundle left in the one merged served tree, and was " + + 'overwritten wherever both defined the key; a stored item OVERRODE the platform copy, because the ' + + 'runtime-authored layer is read over the shipped bundles. Overrides now give way to the platform ' + + 'copy, gaps fall back to the manifest literal, and the group stays on the PLATFORM bundle, ' + + 'PlatformTranslationData', apply(stack, emit) { /** The top-level groups a translation bundle entry may carry (either face). */ const GROUPS = new Set([ @@ -7171,9 +7187,10 @@ const translationPerAppSettingsRemoved: MetadataConversion = { 'pages', 'flows', 'settings', 'metadataForms', 'settingsCommon', ]); return mapCollection(stack, 'translations', (entry, path) => { - // A `translation` ITEM, not a bundle — `settings` is still declared - // there. Leave it whole. - if ('locale' in entry) return entry; + // A bare `translation` ITEM: strip its own top-level group only. + if ('locale' in entry || Object.keys(entry).some((k) => GROUPS.has(k))) { + return stripKeys(entry, ['settings'], emit, path); + } let next = entry; for (const [locale, data] of Object.entries(entry)) { if (!isDict(data) || !isDict(data.settings)) continue; @@ -7196,6 +7213,20 @@ const translationPerAppSettingsRemoved: MetadataConversion = { apps: { crm: { label: '客户关系管理' } }, }, }, + { + // The bare item shape a stored `translation` row replays as. + name: 'ja_jp', + locale: 'ja-JP', + settings: { mail: { title: 'メール配信' } }, + messages: { commonSave: '保存' }, + }, + { + // A row written before `locale` was required — told from a bundle + // entry by its top-level group key. The object literally NAMED + // `settings` is application copy under `objects` and stays. + name: 'fr', + objects: { settings: { label: 'Paramètres' } }, + }, ], }, after: { @@ -7205,10 +7236,19 @@ const translationPerAppSettingsRemoved: MetadataConversion = { apps: { crm: { label: '客户关系管理' } }, }, }, + { + name: 'ja_jp', + locale: 'ja-JP', + messages: { commonSave: '保存' }, + }, + { + name: 'fr', + objects: { settings: { label: 'Paramètres' } }, + }, ], }, - // One per stripped group: the single `zh-CN` entry. - expectedNotices: 1, + // One per stripped group: the `zh-CN` bundle entry and the `ja-JP` item. + expectedNotices: 2, }, }; diff --git a/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts b/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts index 2f19d4948ea..7587802b2c0 100644 --- a/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts +++ b/packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts @@ -5,12 +5,20 @@ import type { SemanticMigration } from '../../types.js'; // The judgment half of `translation-per-app-settings-removed`. The D2 // conversion deletes the group mechanically; what it cannot say in a -// `to: '(removed)'` notice is WHERE those strings were rendering — only in the -// gaps the platform's own bundle left — and that deleting them sends those -// gaps back to the manifest's English literal. +// `to: '(removed)'` notice is WHERE those strings were rendering and what +// renders once they are gone — and the answer differs by door. From a per-app +// bundle they only ever filled gaps the platform's own bundle left, and those +// gaps go back to the manifest's English literal. From a `translation` item +// they OVERRODE the platform's copy (the runtime-authored layer is read over +// the shipped bundles), and those keys go back to the platform's string. +// Extended from the bundle door to the item door by #19620 (ruling batch #210 +// item 2 letter B) rather than duplicated: one group, one ownership rule, one +// entry. export const entry: SemanticMigration = { id: 'translation-per-app-settings-platform-only', - surface: 'stack.translations[]..settings — the per-app bundle’s settings group', + surface: + 'stack.translations[]..settings and translation.settings — the settings group on the ' + + 'per-app bundle and on the registered `translation` item', // The group names are DERIVED from `TranslationDataSchema.shape`, never typed // out beside it. A hand-maintained copy of a schema's key set is the construct // that drifted to nine-of-ten in this very message, so the copy is deleted @@ -18,66 +26,82 @@ export const entry: SemanticMigration = { // the per-app face reaches this sentence the day it is declared. // `Object.keys` on a zod object shape yields the declaration order of the // literal it was built from — the order this sentence promises the operator. + // The `translation` item declares the same groups (plus `locale` and its + // identity/envelope keys), so the one derived list answers both doors. // A getter, not an eager template: importing the registry must not force the // lazy translation schema at module load. get replacement(): string { const groups = Object.keys(TranslationDataSchema.shape); - return 'Delete the group from the per-app bundle. There is no per-app replacement key: settings copy ' - + 'is not application-authorable at all. `settings` is keyed by `SettingsManifest.namespace`, ' - + 'and only platform code declares a manifest ' - + '(`packages/services/service-settings/src/manifests/*.manifest.ts`), so the only namespaces a ' - + 'per-app entry could ever address were the platform’s own. Platform settings copy is ' + return 'Delete the group from the per-app bundle and from every `translation` item. There is no ' + + 'application-side replacement key: settings copy is not application-authorable at either door. ' + + '`settings` is keyed by `SettingsManifest.namespace`, and only platform code declares a manifest ' + + '(`packages/services/service-settings/src/manifests/*.manifest.ts`), so the only namespaces an ' + + 'application could ever address were the platform’s own. Platform settings copy is ' + 'translated in the PLATFORM bundle — `@objectstack/service-settings`’s ' + '`settingsBuiltinTranslations`, typed `PlatformTranslationData` — which is where a correction ' + 'to a platform string belongs. An application’s own copy goes in the ' - + `${groups.length} groups the per-app bundle still declares, in the order it declares them: ` + + `${groups.length} groups the per-app bundle and the \`translation\` item still declare, in the ` + + 'order they declare them: ' + groups.map((g) => `\`${g}\``).join(', ') - + '. Note `settingsCommon` among them: it IS on this face, so the Settings UI shell strings an ' + + '. Note `settingsCommon` among them: it IS on both faces, so the Settings UI shell strings an ' + 'application may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT ' + 'what is being removed here — only the per-namespace manifest copy under `settings` is.'; }, reason: - 'Not losslessly convertible, and NOT because the content was inert — but not because it ' - + 'overrode anything either. Measured on this tree before the split: ' - + '`AppPlugin.loadTranslations` hands each `stack.translations` bundle entry WHOLE to ' + 'Not losslessly convertible, and NOT because the content was inert: what it did differs by door, ' + + 'and both effects are visible on screen. THE PER-APP BUNDLE — measured on this tree before the ' + + 'split: `AppPlugin.loadTranslations` hands each `stack.translations` bundle entry WHOLE to ' + '`II18nService.loadTranslations`, the adapter deep-merges it into the one per-locale tree, and ' + 'every platform plugin contributes into that same tree — so `settings` from an app bundle and ' + '`settings` from `@objectstack/service-settings` land in one place. `resolveSettingsTitle` and ' + 'the rest of the `resolveSettings*` family read it (`pickSettingsEntry` → ' + "`pickData(bundle, locale)?.settings`), and so does the console's `useSettingsLabel`, which " - + 'scans every namespace carrying a `settings` branch; the liveness ledger ' - + '`packages/spec/liveness/translation.json` records that reader with its evidence pointer. ' - + 'ORDER decides the rest, and it runs against the application: `AppPlugin` loads the app’s ' - + 'bundles in its own `start()` (kernel Phase 2), `SettingsServicePlugin` contributes the ' - + 'platform’s settings translations from a `kernel:ready` hook (Phase 3), and `deepMerge` gives ' - + 'the LATER source the leaf — `AppPlugin`’s own comment says as much (“the platform bundles have ' - + 'not arrived yet at this point in the lifecycle”). So the platform won every key both bundles ' - + 'defined, and what an application actually had was a GAP FILLER on a namespace it does not own: ' - + 'the entry rendered only where the platform bundle carried no string for that key and locale ' - + '(the platform ships en / zh-CN / ja-JP / es-ES), silently, with no way for the author to tell ' - + 'a filled gap from an ignored override. Dropping the group therefore takes those gaps back to ' - + 'the manifest’s own literal — the `?? fallback` every `resolveSettings*` helper ends in, which ' - + 'is English — and that is a VISIBLE change to what a Settings screen renders, not a no-op, ' - + 'which a mechanical notice reading "(removed)" does not convey. The two bundles are separate ' - + 'namespaces from this major on (ruling batch #132 item 2 letter ②, 2026-09-13; ADR-0049 ' - + 'enforce-or-remove supplied the question, not the answer — the maintainer struck the card’s own ' - + 'removal disposition, because `settings` is a LIVE platform key). No deprecation window: the ' - + 'per-app door refuses the key by name from this major, with the prescription on the rejection.', + + 'scans every namespace carrying a `settings` branch. ORDER decides the rest, and it runs against ' + + 'the application: `AppPlugin` loads the app’s bundles in its own `start()` (kernel Phase 2), ' + + '`SettingsServicePlugin` contributes the platform’s settings translations from a `kernel:ready` ' + + 'hook (Phase 3), and `deepMerge` gives the LATER source the leaf — `AppPlugin`’s own comment ' + + 'says as much (“the platform bundles have not arrived yet at this point in the lifecycle”). So ' + + 'the platform won every key both bundles defined, and what a per-app bundle actually had was a ' + + 'GAP FILLER on a namespace it does not own: the entry rendered only where the platform bundle ' + + 'carried no string for that key and locale (the platform ships en / zh-CN / ja-JP / es-ES), ' + + 'silently, with no way for the author to tell a filled gap from an ignored override. Dropping ' + + 'it takes those gaps back to the manifest’s own literal — the `?? fallback` every ' + + '`resolveSettings*` helper ends in, which is English. THE `translation` ITEM went further: a ' + + 'stored item is not loaded into the static tree at all but into the runtime-authored layer ' + + '(`authored-translation-sync` → `replaceAuthoredTranslations`), and both i18n adapters read ' + + 'that layer OVER the shipped bundles (`deepMerge(static, authored)`), whatever order they loaded ' + + 'in. So an item’s `settings` OVERRODE the platform’s own copy for its locale — a published item ' + + 'could rewrite a platform Settings screen — which is exactly what the ownership ruling says an ' + + 'application must not do. Dropping it takes each overridden key back to the platform bundle’s ' + + 'string, and each key it had filled back to the manifest literal. A mechanical notice reading ' + + '"(removed)" conveys neither. The two bundles are separate namespaces from this major on ' + + '(ruling batch #132 item 2 letter ②, 2026-09-13), and the item door follows the file door ' + + '(ruling batch #210 item 2 letter B, 2026-09-22: the file door and the item door are two ' + + 'authoring surfaces for ONE app metadata type, so they accept one shape; an admin override of ' + + 'platform copy, if ever wanted, is a platform-level feature, not app metadata). ADR-0049 ' + + 'enforce-or-remove supplied the question, not the answer — `settings` stays a LIVE platform ' + + 'key. No deprecation window: both doors refuse the key by name from this major, with the ' + + 'prescription on the rejection.', acceptanceCriteria: - 'No per-app bundle carries `settings`: `defineTranslationBundle({ : { settings: … } })` ' - + 'and a `defineStack({ translations: [...] })` entry carrying it are both refused as an ' - + 'unrecognized key, and the refusal names the group as platform-only rather than suggesting a ' - + 'rename (pinned in `packages/spec/src/system/translation.test.ts`). The platform face still ' - + 'accepts it: `PlatformTranslationDataSchema.parse({ settings: … })` succeeds, ' - + '`settingsBuiltinTranslations` still type-checks, and `GET /api/v1/i18n/translations/:locale` ' - + 'still declares `settings` on its response (`GetTranslationsResponseSchema`), because the ' - + 'served document is the merged tree. The registered `translation` metadata type is unchanged ' - + 'and still declares `settings`. For a deployment that WAS authoring per-app settings copy: the ' - + 'screens to re-read after the upgrade are the ones where it was FILLING A GAP — a namespace, ' - + 'key or locale the platform bundle does not translate — because those now render the ' - + 'manifest’s own literal, which is English. Everywhere the platform already carried the string, ' - + 'nothing changes on screen: the platform value was already the one being served. If a platform ' - + 'string is wrong or missing for your locale, correct it in the platform bundle ' - + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add the ' - + 'app-side copy, which the platform overwrites on every boot wherever it has its own value.', + 'No application-authored face carries `settings`. `defineTranslationBundle({ : { ' + + 'settings: … } })`, a `defineStack({ translations: [...] })` entry carrying it, ' + + '`defineTranslation({ locale, settings: … })` and a `translation` item saved through the ' + + 'metadata API carrying it are all refused as an unrecognized key, and each refusal names the ' + + 'group as platform-only rather than suggesting a rename (pinned in ' + + '`packages/spec/src/system/translation.test.ts`; the metadata door answers `422 ' + + 'INVALID_METADATA`, pinned in `packages/metadata-protocol`). The platform face still accepts it: ' + + '`PlatformTranslationDataSchema.parse({ settings: … })` succeeds, `settingsBuiltinTranslations` ' + + 'still type-checks, and `GET /api/v1/i18n/translations/:locale` still declares `settings` on ' + + 'its response (`GetTranslationsResponseSchema`), because the served document is the merged ' + + 'tree. A `translation` row ALREADY STORED with `settings` is not refused — a stored row has no ' + + 'author to teach — it is converted: the runtime sync replays this conversion before merging the ' + + 'row, logs the conversion notice once, and loads the rest of the item, so its `settings` stops ' + + 'overriding at the next sync; `os migrate meta --stored --apply` persists the canonical row. ' + + 'For a deployment that WAS authoring settings copy, re-read the Settings screens in each locale ' + + 'it covered: where a `translation` item overrode a platform string, the platform’s string ' + + 'renders again; where either door filled a GAP — a namespace, key or locale the platform bundle ' + + 'does not translate — the manifest’s own literal renders, which is English. If a platform string ' + + 'is wrong or missing for your locale, correct it in the platform bundle ' + + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add ' + + 'app-side copy at either door, which is refused.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 259122158e3..c9a5deb6615 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5228,10 +5228,15 @@ const step18: MigrationStep = { + '(Phase 2) and the platform’s at `kernel:ready` (Phase 3), and `deepMerge` gives the later ' + 'source the leaf, so what an application had was a GAP FILLER on a namespace it does not own ' + '— rendering only where the platform bundle carried no string for that key and locale. The ' - + 'D2 conversion strips the group from per-app bundle entries only (never from a `translation` ' - + 'ITEM, which still declares it), and the paired semantic entry says what the strip means, ' - + 'because a notice reading "(removed)" does not say that those gaps fall back to the ' - + "manifest's own English literal. " + + 'registered `translation` ITEM follows the file door (#19620, ruling batch #210 item 2 letter ' + + 'B: one app metadata type, two authoring doors, one accepted shape) and no longer declares ' + + '`settings` either; there the group had been STRONGER, because the runtime-authored layer is ' + + 'read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 ' + + 'conversion strips the group from per-app bundle entries and from bare items alike — the ' + + 'runtime translation sync replays it over every stored row before merging — and the paired ' + + 'semantic entry says what the strip means at each door, because a notice reading "(removed)" ' + + 'says neither that an item’s overrides give way to the platform’s string nor that a gap falls ' + + "back to the manifest's own English literal. " + 'Finally it retires object `tenancy.organizationField` (#19054, ADR-0049 ' + 'enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as ' + 'opposed to the column the object is WALLED by (`tenantField`); on an ordinary object ' @@ -12737,12 +12742,20 @@ const step18: MigrationStep = { }, // The judgment half of `translation-per-app-settings-removed`. The D2 // conversion deletes the group mechanically; what it cannot say in a - // `to: '(removed)'` notice is WHERE those strings were rendering — only in the - // gaps the platform's own bundle left — and that deleting them sends those - // gaps back to the manifest's English literal. + // `to: '(removed)'` notice is WHERE those strings were rendering and what + // renders once they are gone — and the answer differs by door. From a per-app + // bundle they only ever filled gaps the platform's own bundle left, and those + // gaps go back to the manifest's English literal. From a `translation` item + // they OVERRODE the platform's copy (the runtime-authored layer is read over + // the shipped bundles), and those keys go back to the platform's string. + // Extended from the bundle door to the item door by #19620 (ruling batch #210 + // item 2 letter B) rather than duplicated: one group, one ownership rule, one + // entry. { id: 'translation-per-app-settings-platform-only', - surface: 'stack.translations[]..settings — the per-app bundle’s settings group', + surface: + 'stack.translations[]..settings and translation.settings — the settings group on the ' + + 'per-app bundle and on the registered `translation` item', // The group names are DERIVED from `TranslationDataSchema.shape`, never typed // out beside it. A hand-maintained copy of a schema's key set is the construct // that drifted to nine-of-ten in this very message, so the copy is deleted @@ -12750,68 +12763,84 @@ const step18: MigrationStep = { // the per-app face reaches this sentence the day it is declared. // `Object.keys` on a zod object shape yields the declaration order of the // literal it was built from — the order this sentence promises the operator. + // The `translation` item declares the same groups (plus `locale` and its + // identity/envelope keys), so the one derived list answers both doors. // A getter, not an eager template: importing the registry must not force the // lazy translation schema at module load. get replacement(): string { const groups = Object.keys(TranslationDataSchema.shape); - return 'Delete the group from the per-app bundle. There is no per-app replacement key: settings copy ' - + 'is not application-authorable at all. `settings` is keyed by `SettingsManifest.namespace`, ' - + 'and only platform code declares a manifest ' - + '(`packages/services/service-settings/src/manifests/*.manifest.ts`), so the only namespaces a ' - + 'per-app entry could ever address were the platform’s own. Platform settings copy is ' + return 'Delete the group from the per-app bundle and from every `translation` item. There is no ' + + 'application-side replacement key: settings copy is not application-authorable at either door. ' + + '`settings` is keyed by `SettingsManifest.namespace`, and only platform code declares a manifest ' + + '(`packages/services/service-settings/src/manifests/*.manifest.ts`), so the only namespaces an ' + + 'application could ever address were the platform’s own. Platform settings copy is ' + 'translated in the PLATFORM bundle — `@objectstack/service-settings`’s ' + '`settingsBuiltinTranslations`, typed `PlatformTranslationData` — which is where a correction ' + 'to a platform string belongs. An application’s own copy goes in the ' - + `${groups.length} groups the per-app bundle still declares, in the order it declares them: ` + + `${groups.length} groups the per-app bundle and the \`translation\` item still declare, in the ` + + 'order they declare them: ' + groups.map((g) => `\`${g}\``).join(', ') - + '. Note `settingsCommon` among them: it IS on this face, so the Settings UI shell strings an ' + + '. Note `settingsCommon` among them: it IS on both faces, so the Settings UI shell strings an ' + 'application may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT ' + 'what is being removed here — only the per-namespace manifest copy under `settings` is.'; }, reason: - 'Not losslessly convertible, and NOT because the content was inert — but not because it ' - + 'overrode anything either. Measured on this tree before the split: ' - + '`AppPlugin.loadTranslations` hands each `stack.translations` bundle entry WHOLE to ' + 'Not losslessly convertible, and NOT because the content was inert: what it did differs by door, ' + + 'and both effects are visible on screen. THE PER-APP BUNDLE — measured on this tree before the ' + + 'split: `AppPlugin.loadTranslations` hands each `stack.translations` bundle entry WHOLE to ' + '`II18nService.loadTranslations`, the adapter deep-merges it into the one per-locale tree, and ' + 'every platform plugin contributes into that same tree — so `settings` from an app bundle and ' + '`settings` from `@objectstack/service-settings` land in one place. `resolveSettingsTitle` and ' + 'the rest of the `resolveSettings*` family read it (`pickSettingsEntry` → ' + "`pickData(bundle, locale)?.settings`), and so does the console's `useSettingsLabel`, which " - + 'scans every namespace carrying a `settings` branch; the liveness ledger ' - + '`packages/spec/liveness/translation.json` records that reader with its evidence pointer. ' - + 'ORDER decides the rest, and it runs against the application: `AppPlugin` loads the app’s ' - + 'bundles in its own `start()` (kernel Phase 2), `SettingsServicePlugin` contributes the ' - + 'platform’s settings translations from a `kernel:ready` hook (Phase 3), and `deepMerge` gives ' - + 'the LATER source the leaf — `AppPlugin`’s own comment says as much (“the platform bundles have ' - + 'not arrived yet at this point in the lifecycle”). So the platform won every key both bundles ' - + 'defined, and what an application actually had was a GAP FILLER on a namespace it does not own: ' - + 'the entry rendered only where the platform bundle carried no string for that key and locale ' - + '(the platform ships en / zh-CN / ja-JP / es-ES), silently, with no way for the author to tell ' - + 'a filled gap from an ignored override. Dropping the group therefore takes those gaps back to ' - + 'the manifest’s own literal — the `?? fallback` every `resolveSettings*` helper ends in, which ' - + 'is English — and that is a VISIBLE change to what a Settings screen renders, not a no-op, ' - + 'which a mechanical notice reading "(removed)" does not convey. The two bundles are separate ' - + 'namespaces from this major on (ruling batch #132 item 2 letter ②, 2026-09-13; ADR-0049 ' - + 'enforce-or-remove supplied the question, not the answer — the maintainer struck the card’s own ' - + 'removal disposition, because `settings` is a LIVE platform key). No deprecation window: the ' - + 'per-app door refuses the key by name from this major, with the prescription on the rejection.', + + 'scans every namespace carrying a `settings` branch. ORDER decides the rest, and it runs against ' + + 'the application: `AppPlugin` loads the app’s bundles in its own `start()` (kernel Phase 2), ' + + '`SettingsServicePlugin` contributes the platform’s settings translations from a `kernel:ready` ' + + 'hook (Phase 3), and `deepMerge` gives the LATER source the leaf — `AppPlugin`’s own comment ' + + 'says as much (“the platform bundles have not arrived yet at this point in the lifecycle”). So ' + + 'the platform won every key both bundles defined, and what a per-app bundle actually had was a ' + + 'GAP FILLER on a namespace it does not own: the entry rendered only where the platform bundle ' + + 'carried no string for that key and locale (the platform ships en / zh-CN / ja-JP / es-ES), ' + + 'silently, with no way for the author to tell a filled gap from an ignored override. Dropping ' + + 'it takes those gaps back to the manifest’s own literal — the `?? fallback` every ' + + '`resolveSettings*` helper ends in, which is English. THE `translation` ITEM went further: a ' + + 'stored item is not loaded into the static tree at all but into the runtime-authored layer ' + + '(`authored-translation-sync` → `replaceAuthoredTranslations`), and both i18n adapters read ' + + 'that layer OVER the shipped bundles (`deepMerge(static, authored)`), whatever order they loaded ' + + 'in. So an item’s `settings` OVERRODE the platform’s own copy for its locale — a published item ' + + 'could rewrite a platform Settings screen — which is exactly what the ownership ruling says an ' + + 'application must not do. Dropping it takes each overridden key back to the platform bundle’s ' + + 'string, and each key it had filled back to the manifest literal. A mechanical notice reading ' + + '"(removed)" conveys neither. The two bundles are separate namespaces from this major on ' + + '(ruling batch #132 item 2 letter ②, 2026-09-13), and the item door follows the file door ' + + '(ruling batch #210 item 2 letter B, 2026-09-22: the file door and the item door are two ' + + 'authoring surfaces for ONE app metadata type, so they accept one shape; an admin override of ' + + 'platform copy, if ever wanted, is a platform-level feature, not app metadata). ADR-0049 ' + + 'enforce-or-remove supplied the question, not the answer — `settings` stays a LIVE platform ' + + 'key. No deprecation window: both doors refuse the key by name from this major, with the ' + + 'prescription on the rejection.', acceptanceCriteria: - 'No per-app bundle carries `settings`: `defineTranslationBundle({ : { settings: … } })` ' - + 'and a `defineStack({ translations: [...] })` entry carrying it are both refused as an ' - + 'unrecognized key, and the refusal names the group as platform-only rather than suggesting a ' - + 'rename (pinned in `packages/spec/src/system/translation.test.ts`). The platform face still ' - + 'accepts it: `PlatformTranslationDataSchema.parse({ settings: … })` succeeds, ' - + '`settingsBuiltinTranslations` still type-checks, and `GET /api/v1/i18n/translations/:locale` ' - + 'still declares `settings` on its response (`GetTranslationsResponseSchema`), because the ' - + 'served document is the merged tree. The registered `translation` metadata type is unchanged ' - + 'and still declares `settings`. For a deployment that WAS authoring per-app settings copy: the ' - + 'screens to re-read after the upgrade are the ones where it was FILLING A GAP — a namespace, ' - + 'key or locale the platform bundle does not translate — because those now render the ' - + 'manifest’s own literal, which is English. Everywhere the platform already carried the string, ' - + 'nothing changes on screen: the platform value was already the one being served. If a platform ' - + 'string is wrong or missing for your locale, correct it in the platform bundle ' - + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add the ' - + 'app-side copy, which the platform overwrites on every boot wherever it has its own value.', + 'No application-authored face carries `settings`. `defineTranslationBundle({ : { ' + + 'settings: … } })`, a `defineStack({ translations: [...] })` entry carrying it, ' + + '`defineTranslation({ locale, settings: … })` and a `translation` item saved through the ' + + 'metadata API carrying it are all refused as an unrecognized key, and each refusal names the ' + + 'group as platform-only rather than suggesting a rename (pinned in ' + + '`packages/spec/src/system/translation.test.ts`; the metadata door answers `422 ' + + 'INVALID_METADATA`, pinned in `packages/metadata-protocol`). The platform face still accepts it: ' + + '`PlatformTranslationDataSchema.parse({ settings: … })` succeeds, `settingsBuiltinTranslations` ' + + 'still type-checks, and `GET /api/v1/i18n/translations/:locale` still declares `settings` on ' + + 'its response (`GetTranslationsResponseSchema`), because the served document is the merged ' + + 'tree. A `translation` row ALREADY STORED with `settings` is not refused — a stored row has no ' + + 'author to teach — it is converted: the runtime sync replays this conversion before merging the ' + + 'row, logs the conversion notice once, and loads the rest of the item, so its `settings` stops ' + + 'overriding at the next sync; `os migrate meta --stored --apply` persists the canonical row. ' + + 'For a deployment that WAS authoring settings copy, re-read the Settings screens in each locale ' + + 'it covered: where a `translation` item overrode a platform string, the platform’s string ' + + 'renders again; where either door filled a GAP — a namespace, key or locale the platform bundle ' + + 'does not translate — the manifest’s own literal renders, which is English. If a platform string ' + + 'is wrong or missing for your locale, correct it in the platform bundle ' + + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add ' + + 'app-side copy at either door, which is refused.', }, { id: 'ui-action-undoable-unfulfillable-refused', From 7ba3d25d970c6e9a8511360e9b00135f04f7057b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:20:06 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat(spec)!:=20the=20translation=20item=20d?= =?UTF-8?q?oor=20refuses=20settings=20=E2=80=94=20platform-only=20at=20bot?= =?UTF-8?q?h=20doors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of the item-door settings retirement: TranslationItemSchema takes the per-app face (appTranslationDataShape only), drops the setting -> settings alias, and answers both spellings with its own platform-only guidance, since on the item the group overrode the platform copy rather than filling gaps. The two pins that asserted the item accepts settings now assert the refusal; the liveness row retires by the strict-delete route; the two docs pages and the checklist anchor that said the item still declares it are corrected. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- .../docs/protocol/kernel/i18n-standard.mdx | 4 +- content/docs/ui/translations.mdx | 12 ++-- docs/qa/platform-checklist/areas/i18n.json | 2 +- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/translation.json | 8 +-- packages/spec/src/system/translation.test.ts | 65 +++++++++++++++-- packages/spec/src/system/translation.zod.ts | 72 +++++++++++++++++-- 7 files changed, 137 insertions(+), 28 deletions(-) diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index 0a41d474b91..d0102b69c7a 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -406,7 +406,9 @@ Three rules the shape enforces, all of them closed since #4001: it.** It is keyed by `SettingsManifest.namespace`, and only platform code declares a manifest — so the only namespaces an application could ever address are the platform's own. Writing it in `stack.translations` (or in - `defineTranslationBundle`) is refused by name with that prescription; the + `defineTranslationBundle`) is refused by name with that prescription, and so + is writing it on a `translation` metadata item (`defineTranslation`, or a save + through the metadata API) — the item door takes the same per-app face; the platform's own bundles author it against `PlatformTranslationData`, and the served document (`GET /i18n/translations/:locale`) carries it because it is the merge of every loaded bundle. diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index d43d6bf1df0..98904a36e47 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -217,11 +217,13 @@ Two things to know: a silent skip is the hardest kind of missing translation to diagnose. - Only the groups on this page are accepted, and since #4001 that is literally true: a key none of them declares is rejected, in a runtime item **and** in a - file-authored bundle. One group differs between the two doors: `settings` is - **platform-only** — a file bundle refuses it by name, and although the - registered `translation` item still declares it, the only namespaces it can - address are the platform's own, because only platform code declares a settings - manifest. Keys from the retired `o.` shape (`o`, `app`, + file-authored bundle. `settings` is **platform-only** and both doors refuse it + by name: it is keyed by a settings manifest's namespace, and only platform code + declares a manifest, so the only namespaces it could address are the platform's + own. On a runtime item it used to be accepted — and because published items + layer over the shipped bundles, it overrode the platform's own Settings copy. A + row stored with it before the door closed loads without that group, with a + warning in the server log. Keys from the retired `o.` shape (`o`, `app`, `nav`, `dashboard`, `_globalOptions`, `_meta`, …) carry a message naming the group to use instead — they used to save cleanly and then render nothing (#3778). Everything else gets the nearest declared key suggested. diff --git a/docs/qa/platform-checklist/areas/i18n.json b/docs/qa/platform-checklist/areas/i18n.json index edcb636e1fd..178842d2eb9 100644 --- a/docs/qa/platform-checklist/areas/i18n.json +++ b/docs/qa/platform-checklist/areas/i18n.json @@ -299,7 +299,7 @@ ], "traps": ["stale-console-bundle", "hydration-race", "wrong-panel", "dispatcher-vs-hono-route"], "source": [ - "packages/spec/src/system/translation.zod.ts#appTranslationDataShape (appTranslationDataShape — the group vocabulary an APPLICATION may author: objects/_views/_actions/_sections, apps.navigation, messages, globalActions, dashboards, datasets, pages, flows, metadataForms, settingsCommon. `settings` is platform-only since #15178 and lives in platformSettingsShape, spread into PlatformTranslationDataSchema and TranslationItemSchema)", + "packages/spec/src/system/translation.zod.ts#appTranslationDataShape (appTranslationDataShape — the group vocabulary an APPLICATION may author: objects/_views/_actions/_sections, apps.navigation, messages, globalActions, dashboards, datasets, pages, flows, metadataForms, settingsCommon. `settings` is platform-only since #15178 and lives in platformSettingsShape, spread into PlatformTranslationDataSchema only — TranslationItemSchema refuses it too since #19620)", "examples/app-showcase/src/system/translations/index.ts (full-column coverage rationale)", "packages/services/service-i18n/src/i18n-service-plugin.ts#i18n (GET /i18n/locales | /translations/:locale | /labels/:object/:locale; { success, data } envelope #3636/#3675; resolveObjectFieldLabels nested shape #3778/#3833; the plugin mount and the dispatcher /i18n domain serve the same routes interchangeably)", "packages/services/service-i18n/src/file-i18n-adapter.ts#getLocales (getLocales / getTranslations — unloaded locale → {}; fallbackLocale applies per-KEY in t(), not to the bulk route)", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 94866be13f0..1b20fc41134 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -926,7 +926,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. 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. | +| 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. **#19620** (ruling batch #210 item 2 letter B): `settings` row DELETED — the strict-delete route, because `TranslationItemSchema` no longer declares the key and refuses it by name (the item door now takes the per-app face, as the file door has since #15178). ⚠️ The deleted row read `live`, and that verdict was TRUE and stays true of the platform: its evidence read the SERVED tree, which the platform bundle feeds, so the deletion retires the key from the application-authored item and nothing else — the capability lives on `PlatformTranslationDataSchema`, outside this ledger. | | 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/translation.json b/packages/spec/liveness/translation.json index 85348ffad70..45722cbe747 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 but `settingsCommon` 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. `settingsCommon` is a fixed strictObject with no target names, so the drill's one level lands on its own named member `sourceLabels` — itself a fixed strictObject whose keys are the ADR-0010 resolution layers (`env`, `global`, `tenant`, `user`, `default`), a closed set the schema holds closed: the retired spellings (`org`/`workspace`, `system`, `fallback`, `environment`) are rejected with a pointer to the layer each meant, never accepted as a layer. Those layer keys sit below the boundary: they are read as one unit by `resolveSettingsSourceLabel` (packages/spec/src/system/i18n-resolver.ts) and objectui's `useSettingsLabel().sourceLabel`, and the blanket verdict `settingsCommon` carries over them is the DECLARED kind — `translation/settingsCommon` is a row of scripts/liveness/undrilled-containers.baseline.json, the same standing as the record groups whose value shapes are not drilled. 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. Every group but `flows` is live — `flows` is the one that is `planned`, and `datasets` was seeded LIVE and DRILLED by #14253 with its reader (`translateDataset`) in the same change. A BOUNDARY and not a total, deliberately: the per-prop rows below carry the verdicts, the generated `state-counts.md` carries this type's totals (#7377), and `props` holds the groups PLUS `locale` and the item-identity keys `name`/`label` — so no total taken over `props` is a total of groups, which is how both totals this sentence has carried came to be wrong. ⚠️ It first 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) to \"11 of 12 groups live; the twelfth, `datasets`, …\", which matched no reading of `props` at all — `datasets` is one of the groups, never a twelfth. Corrected again 2026-09-06 (#15775) by deleting the integers rather than re-deriving them, on #7377's precedent for this ledger family's other hand-maintained counts. 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 but `settingsCommon` 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, apps..navigation..label, …) are governed by the resolvers cited per row, not by ledger rows. `settingsCommon` is a fixed strictObject with no target names, so the drill's one level lands on its own named member `sourceLabels` — itself a fixed strictObject whose keys are the ADR-0010 resolution layers (`env`, `global`, `tenant`, `user`, `default`), a closed set the schema holds closed: the retired spellings (`org`/`workspace`, `system`, `fallback`, `environment`) are rejected with a pointer to the layer each meant, never accepted as a layer. Those layer keys sit below the boundary: they are read as one unit by `resolveSettingsSourceLabel` (packages/spec/src/system/i18n-resolver.ts) and objectui's `useSettingsLabel().sourceLabel`, and the blanket verdict `settingsCommon` carries over them is the DECLARED kind — `translation/settingsCommon` is a row of scripts/liveness/undrilled-containers.baseline.json, the same standing as the record groups whose value shapes are not drilled. 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. `settings` is NOT a group of this ledger any more: its row was DELETED 2026-09-24 (#19620, ruling batch #210 item 2 letter B) by the strict-delete route — TranslationItemSchema no longer declares it and refuses it by name with the platform-only prescription, so the key left the walked shape and a surviving row would be an ORPHAN. That row was `live` on evidence reading the SERVED tree (objectui useSettingsLabel), which the PLATFORM bundle feeds; the deletion retires the key from this item ledger ONLY and says nothing about the platform capability, which stays declared on PlatformTranslationDataSchema (not this ledger’s subject) and read by the resolveSettings* family. Stored rows written before the door closed are converted, not read raw: authored-translation-sync replays the ADR-0087 chain (translation-per-app-settings-removed) over each row before merging it, which retires the `settings` clause of the RAW-payload note above for that key. Every group but `flows` is live — `flows` is the one that is `planned`, and `datasets` was seeded LIVE and DRILLED by #14253 with its reader (`translateDataset`) in the same change. A BOUNDARY and not a total, deliberately: the per-prop rows below carry the verdicts, the generated `state-counts.md` carries this type's totals (#7377), and `props` holds the groups PLUS `locale` and the item-identity keys `name`/`label` — so no total taken over `props` is a total of groups, which is how both totals this sentence has carried came to be wrong. ⚠️ It first 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) to \"11 of 12 groups live; the twelfth, `datasets`, …\", which matched no reading of `props` at all — `datasets` is one of the groups, never a twelfth. Corrected again 2026-09-06 (#15775) by deleting the integers rather than re-deriving them, on #7377's precedent for this ledger family's other hand-maintained counts. 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", @@ -111,12 +111,6 @@ }, "note": "[#7646] Contract-first spec half of the screen-flow localization split, and `planned` is the honest status rather than `live` or `dead`: `dead` means declared with no consumer and no plan, while this group was ruled into the vocabulary by the maintainer specifically so the runner half could be built against it (the same ruling fixes the boundary — runner chrome, Cancel/Submit, stays in the console's own message catalog, NOT here). Addressing is measured against what the runner already holds: `flows..screens.` — the node id reaches the client verbatim as `ScreenSpec.nodeId` (packages/spec/src/contracts/automation-service.ts:138), which is also what correlates a resume back to its pause point — and `.fields.` (packages/spec/src/automation/builtin-node-config.zod.ts:382, forwarded as `ScreenFieldSpec.name`). Key face measured against `ScreenFieldConfigSchema`, not mirrored from the report: `label` + `placeholder` are declared, `help` is NOT — but ⚠️ no longer for its original reason: #17306 gave the screen field `ScreenFieldConfig.inlineHelpText`, so the help copy is REAL and what is missing is only THIS face's translation key for it. Growing that face is a ruled step against the #7646 enumeration, not a resolver-side accretion, so until it lands a `help` entry here would still parse clean and translate nothing — the ADR-0078 shape #6080 kept out of the page-component face, on a not-yet reason rather than an absent-key one; it rides `guidance` on the field surface instead, alongside `options`, which cannot be addressed by a value-keyed map because `ScreenFieldConfig.options[].value` is unconstrained. Flip to `live` with an objectui screen-flow-runner evidence pointer when the downstream consumer card lands; the resolver-side helper (a `FLOW_SCREEN_COPY_KEYS` sibling of `PAGE_COMPONENT_COPY_KEYS` in packages/spec/src/system/i18n-resolver.ts) is deliberately NOT in this change — #7634 was in flight on that file." }, - "settings": { - "status": "live", - "verifiedAt": "2026-08-01", - "evidence": "objectui @940ba24: apps/console/src/pages/settings/useSettingsLabel.ts:78", - "note": "the Settings UI resolves `.settings..{title,description,groups.*,keys.*,actions.*}` against the served tree — title/group/field/option/action labels all honored." - }, "metadataForms": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index dd6baecda7e..8b5319b7259 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1276,8 +1276,10 @@ describe('translation unknown-key strictness (#4001)', () => { it('still accepts every declared group together', () => { // The shape is spread into three schemas (per-app bundle entry, platform // bundle entry, metadata item); this is the guard against closing one of - // them against a stale key list. `settings` is the one group the per-app - // face does NOT take, so it is authored separately below. + // them against a stale key list. `settings` is the one group only the + // PLATFORM face takes (the item left it with #19620), so it is authored + // separately below and its refusal at both application doors is pinned in + // the block after this one. const body = { objects: { account: { label: 'Account', _views: { all: { label: 'All', emptyState: { title: 'None' } } } } }, apps: { crm: { label: 'CRM', navigation: { sales: { label: 'Sales' } } } }, @@ -1292,7 +1294,14 @@ describe('translation unknown-key strictness (#4001)', () => { const settings = { mail: { title: 'Mail', keys: { host: { label: 'Host' } } } }; expect(() => TranslationDataSchema.parse(body)).not.toThrow(); expect(() => PlatformTranslationDataSchema.parse({ ...body, settings })).not.toThrow(); - expect(() => TranslationItemSchema.parse({ locale: 'en', ...body, settings })).not.toThrow(); + expect(() => TranslationItemSchema.parse({ locale: 'en', ...body })).not.toThrow(); + // #19620: the SAME full body plus `settings` is refused on the item — and + // only for `settings`, so the refusal cannot be a stale key list elsewhere. + const item = TranslationItemSchema.safeParse({ locale: 'en', ...body, settings }); + expect(item.success).toBe(false); + expect(item.error?.issues.map((i) => [i.code, (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', ['settings']], + ]); }); // ────────────────────────────────────────────────────────────────────────── @@ -1329,12 +1338,54 @@ describe('translation unknown-key strictness (#4001)', () => { expect(() => defineTranslationBundle({ 'zh-CN': { settings } } as never)).toThrow(/PLATFORM group/s); }); - it('still accepts it on the platform face and on the registered `translation` item', () => { - // The over-acceptance control for the refusals above: the group did not - // leave the contract, it left ONE of its three faces. + it('still accepts it on the platform face', () => { + // The over-acceptance control for the refusals above and below: the + // group did not leave the contract, it left the two APPLICATION faces + // and stays on the platform's. expect(() => PlatformTranslationDataSchema.parse({ settings })).not.toThrow(); expect(() => PlatformTranslationBundleSchema.parse({ 'zh-CN': { settings } })).not.toThrow(); - expect(() => TranslationItemSchema.parse({ locale: 'zh-CN', settings })).not.toThrow(); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // #19620 — the `translation` ITEM door refuses `settings` too (ruling batch + // #210 item 2 letter B: two authoring doors, one app metadata type, one + // accepted shape). It used to ACCEPT it — and there it overrode the + // platform's copy, not merely filled gaps. + // ────────────────────────────────────────────────────────────────────────── + describe('item-door `settings` is refused with the platform-only prescription (#19620)', () => { + const settings = { mail: { title: 'Mail', keys: { host: { label: 'Host' } } } }; + + it.each([ + ['`settings`', 'settings'], + // The singular was an ALIAS for `settings` on this door too; it rides + // the prescription now, never a rename into a second rejection. + ['the singular `setting`', 'setting'], + ])('refuses %s on a `translation` item', (_what, key) => { + const result = TranslationItemSchema.safeParse({ locale: 'zh-CN', [key]: settings }); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path).toEqual([]); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual([key]); + const message = issue?.message ?? ''; + // The prescription names the group platform-only and the face that + // takes it — not a rename, which would send the author into a second + // rejection. + expect(message).toContain('PLATFORM group'); + expect(message).toContain('PlatformTranslationData'); + expect(message).not.toContain(`\`${key}\` →`); + }); + + it('refuses it through `defineTranslation`, the file-authored item door', () => { + expect(() => defineTranslation({ locale: 'zh-CN', settings } as never)).toThrow(/PLATFORM group/s); + }); + + it('CONTROL — the same item without `settings` parses, `settingsCommon` included', () => { + expect(() => TranslationItemSchema.parse({ + locale: 'zh-CN', + settingsCommon: { sourceLabels: { tenant: '租户' } }, + apps: { crm: { label: '客户关系管理' } }, + })).not.toThrow(); }); }); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index fb7ad26f0a9..0234dfea2b0 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -601,6 +601,47 @@ const APP_TRANSLATION_KEY_GUIDANCE: Record = { setting: PER_APP_SETTINGS_PLATFORM_ONLY, }; +/** + * The `translation` ITEM door's answer for `settings` (#19620, ruling batch + * #210 item 2 letter B: the file door and the item door are two authoring + * surfaces for one app metadata type, so they accept one shape). + * + * Its own sentence rather than {@link PER_APP_SETTINGS_PLATFORM_ONLY} reused, + * because what the key DID differs by door and the prescription must not + * understate it. A bundle entry only filled gaps (the platform's later + * `kernel:ready` contribution won every key both defined). An item did not + * load into that tree at all: `authored-translation-sync` puts it in the + * runtime-authored layer, which both i18n adapters read OVER the shipped + * bundles (`deepMerge(static, authored)`), so an item's `settings` OVERRODE the + * platform's own copy. Telling an item author "the platform overwrote it + * anyway" would be false. The singular `setting` rides the same entry, as on + * the bundle door: it was an alias for `settings` while this door declared it. + */ +const ITEM_SETTINGS_PLATFORM_ONLY = + '`settings` is a PLATFORM group, not an application one: it is keyed by ' + + '`SettingsManifest.namespace`, and a manifest is platform code — an application cannot ' + + 'declare one, so the only namespaces this key could address are the platform\'s own. ' + + 'On a `translation` item it did more than fill gaps: the runtime-authored layer is read over ' + + 'the shipped bundles, so an item\'s entry OVERRODE the platform\'s own settings copy for its ' + + 'locale — application metadata rewriting a platform screen. ' + + 'Delete the group. Platform settings copy is translated in the platform bundle ' + + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + + 'literal, so correct it there rather than overriding it from an application. For an ' + + 'application\'s own copy use the groups this item does declare — the same ten a per-app ' + + "bundle declares, 'settingsCommon' among them: the Settings UI shell strings an application " + + 'may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT what is being ' + + "refused here — only the per-namespace manifest copy under 'settings' is. " + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply ' + + 'them by hand.'; + +/** The item door's guidance: the shared table plus the platform-only `settings`. */ +const ITEM_TRANSLATION_KEY_GUIDANCE: Record = { + ...TRANSLATION_KEY_GUIDANCE, + settings: ITEM_SETTINGS_PLATFORM_ONLY, + setting: ITEM_SETTINGS_PLATFORM_ONLY, +}; + // ──────────────────────────────────────────────────────────────────────────── // Locale-level Translation Data (per-locale aggregate) // ──────────────────────────────────────────────────────────────────────────── @@ -633,7 +674,8 @@ const APP_TRANSLATION_KEY_GUIDANCE: Record = { * entry of a per-app file-authored bundle — these ten and no more), * {@link PlatformTranslationDataSchema} (these ten plus `settings`) and * {@link TranslationItemSchema} (the registered `translation` metadata type — - * the platform face plus `locale` and the ADR-0010 envelope). The item used to + * the per-app face plus `locale`, its identity keys and the ADR-0010 + * envelope; it carried the platform face's `settings` too until #19620). The item used to * reach them with `.extend()`, which is correct for the *validation* but wrong * for the *error*: `.extend()` inherits the strict error map that closed over * the keys the BASE was built with, so a typo of `locale` on an item would be @@ -1312,8 +1354,12 @@ const appTranslationDataShape = () => ({ * string for that key and locale, and was overwritten wherever both defined the * key. Platform labels and application labels are separate namespaces (ruling * batch #132 item 2 letter ②), so this shape is spread into - * {@link PlatformTranslationDataSchema} and {@link TranslationItemSchema} and - * NOT into {@link TranslationDataSchema}, whose door refuses it by name. + * {@link PlatformTranslationDataSchema} ONLY. Both application-authored doors + * refuse it by name: {@link TranslationDataSchema} since #15178, and + * {@link TranslationItemSchema} since #19620 (ruling batch #210 item 2 + * letter B) — the item had been the stronger of the two, because the + * runtime-authored layer it feeds is read OVER the shipped bundles, so an + * item's `settings` overrode the platform's copy rather than filling its gaps. * * A function, not a `const`, for the same reason * {@link appTranslationDataShape} is one. @@ -1559,6 +1605,17 @@ export type TranslationConfig = z.input; * sync skips an item whose locale it cannot resolve, and a skip is invisible * to whoever — or whatever — authored it. * + * `settings` is NOT on this door (#19620, ruling batch #210 item 2 letter B): + * the item takes the PER-APP face, the same ten groups as + * {@link TranslationDataSchema}, and refuses `settings` (and the singular + * `setting`) by name with {@link ITEM_SETTINGS_PLATFORM_ONLY} as the remedy. + * The file door and the item door are two authoring surfaces for one app + * metadata type, so they accept one shape; settings copy belongs to the + * platform bundle ({@link PlatformTranslationDataSchema}). A row stored + * before the door closed is converted rather than refused — the runtime sync + * replays the ADR-0087 chain over it (`translation-per-app-settings-removed`) + * and drops the group with a warning. + * * `messages` ids are single-segment, for the reason spelled out on * {@link TranslationDataSchema}: `t()` walks the dot path, so an id containing * a dot resolves to nothing. @@ -1583,11 +1640,14 @@ export type TranslationConfig = z.input; export const TranslationItemSchema = lazySchema(() => strictObject({ surface: 'this translation', history: TRANSLATION_HISTORY, - guidance: TRANSLATION_KEY_GUIDANCE, - aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, + guidance: ITEM_TRANSLATION_KEY_GUIDANCE, + // ⛔ No `setting: 'settings'` alias any more, for the file door's reason: + // this door no longer declares `settings`, and an alias prescribing a key + // the shape rejects is a suggestion the author cannot take. Both spellings + // are answered by `guidance` above instead. + aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', flow: 'flows', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, }, { ...appTranslationDataShape(), - ...platformSettingsShape(), locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), // Item identity. Every other registered metadata type declares these; From d94e300e4ce1a5c970e72389ddfe7b6229e4f798 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:28:01 +0000 Subject: [PATCH 3/5] chore(spec): regenerate artefacts for the item-door settings retirement The authorable-surface tripwire line system/TranslationItem:settings is deleted deliberately (the build's check (c) adjudicates it by proof 4, the guidance route); content/docs/references/system/translation.mdx and liveness/state-counts.md are regenerated with gen:docs and gen:liveness-counts. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- content/docs/references/system/translation.mdx | 11 ----------- packages/spec/authorable-surface/system.json | 1 - packages/spec/liveness/state-counts.md | 4 ++-- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 7f1755bef65..a867f2e0205 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -506,7 +506,6 @@ One locale of translations — the `translation` metadata type | **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | -| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | | **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") | | **name** | `string` | optional | Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent | | **label** | `string` | optional | Human-readable label shown in metadata lists | @@ -604,16 +603,6 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **sourceLabels** | `{ env?: string; global?: string; tenant?: string; user?: string; … }` | optional | Source badge labels by resolution layer | -### Nested Shape: `TranslationItem.settings[string]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **title** | `string` | optional | Translated settings manifest title | -| **description** | `string` | optional | Translated settings manifest description | -| **groups** | `Record` | optional | Group translations keyed by group key | -| **keys** | `Record }>` | optional | Per-setting field translations keyed by setting key | -| **actions** | `Record` | optional | Action button translations keyed by action id | - --- diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index b3958cde2ec..f45d62f762d 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1314,7 +1314,6 @@ "system/TranslationItem:name", "system/TranslationItem:objects", "system/TranslationItem:pages", - "system/TranslationItem:settings", "system/TranslationItem:settingsCommon", "system/VectorClock:clock", "system/WorkerStats:active", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 013eabeed4a..03874903b7e 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -52,7 +52,7 @@ for both corollaries. | `job` | 15 | 0 | 0 | 1 | 0 | 16 | | `mapping` | 14 | 0 | 0 | 0 | 0 | 14 | | `seed` | 13 | 0 | 0 | 0 | 0 | 13 | -| `translation` | 23 | 0 | 0 | 0 | 2 | 25 | +| `translation` | 22 | 0 | 0 | 0 | 2 | 24 | | `validation` | 18 | 0 | 0 | 0 | 0 | 18 | | `api` | 25 | 0 | 0 | 1 | 2 | 28 | | `capability` | 12 | 0 | 0 | 0 | 0 | 12 | @@ -67,4 +67,4 @@ for both corollaries. | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 44 | 1 | 74 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **932** | **5** | **1** | **168** | **11** | **1117** | +| **total** | **931** | **5** | **1** | **168** | **11** | **1116** | From 889861a041a0fd1ce5b02d18a8c21050e8b55173 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 00:44:37 +0000 Subject: [PATCH 4/5] fix(spec): the stored conversion pass reaches translation rows; door-level pins and changeset Measured: applyConversionsToStoredItem returned every stored `translation` row untouched, because the manifest-collection maps carry no `translations` spelling, so no rehydration seam ever replayed a translation conversion over a stored row. The pass now maps `translation` (and the legacy plural row spelling) to the `translations` collection the translation conversions walk. Also: the metadata door's 422 INVALID_METADATA refusal of an item carrying settings is pinned (code + status + platform-only prescription, nothing stored, with a control); the stale undrilled-container row translation/settings leaves the shrink-only baseline; the changeset is added and the unreleased sibling changeset's "item unchanged" line is corrected. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- ...ion-bundle-split-settings-platform-only.md | 5 +- ...translation-item-settings-platform-only.md | 71 +++++++++++++++++++ ...nvalid-metadata-422-face-inventory.test.ts | 56 +++++++++++++++ .../undrilled-containers.baseline.json | 1 - packages/spec/src/conversions/registry.ts | 6 +- packages/spec/src/conversions/stored.test.ts | 28 ++++++++ packages/spec/src/conversions/stored.ts | 27 ++++++- 7 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 .changeset/19620-translation-item-settings-platform-only.md diff --git a/.changeset/15178-translation-bundle-split-settings-platform-only.md b/.changeset/15178-translation-bundle-split-settings-platform-only.md index 261d92a1429..db820897410 100644 --- a/.changeset/15178-translation-bundle-split-settings-platform-only.md +++ b/.changeset/15178-translation-bundle-split-settings-platform-only.md @@ -66,8 +66,9 @@ and the rejection carries the prescription above. ### Unchanged -The registered `translation` metadata type (`TranslationItemSchema`) still -declares `settings` — this ruling covers the file-authored bundle. `GET +The registered `translation` metadata type (`TranslationItemSchema`) is not +changed by THIS entry — this ruling covers the file-authored bundle. (Superseded +in the same release: #19620 narrows the item door too; see its own changeset.) `GET /api/v1/i18n/translations/:locale` still declares it on its response, because the served document is the merged tree; `GetTranslationsResponseSchema` is typed against the platform face for exactly that reason. diff --git a/.changeset/19620-translation-item-settings-platform-only.md b/.changeset/19620-translation-item-settings-platform-only.md new file mode 100644 index 00000000000..dcae7e344ad --- /dev/null +++ b/.changeset/19620-translation-item-settings-platform-only.md @@ -0,0 +1,71 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +--- + +**BREAKING for runtime-authored `translation` items** — the registered `translation` metadata type no longer declares `settings`: platform settings copy is platform-only at BOTH application doors (#19620) + +Clause-②: no + +`TranslationItemSchema` — one `translation` metadata item, authored with +`defineTranslation`, in Studio, or through the metadata API — now takes the same +ten groups as a per-app bundle entry (`TranslationData`). `settings`, and its +singular `setting`, are refused by name with the platform-only prescription, +exactly as the per-app bundle has refused them since #15178. The file door and +the item door are two authoring surfaces for one app metadata type, so they +accept one shape. + +### Migration — FROM → TO + +| You wrote | Write instead | +| --- | --- | +| `defineTranslation({ locale: 'zh-CN', settings: { mail: { title: '邮件投递' } } })` | delete the `settings` group — there is no application-side replacement key | +| a `translation` item saved through the metadata API or Studio carrying `settings` | delete the `settings` group; the save answers `422 INVALID_METADATA` until you do | +| `const t: TranslationItem = { locale: 'en', settings: … }` | move the copy to the PLATFORM bundle (`PlatformTranslationData`), or delete it | + +**The one-line fix: delete the `settings` group from the item.** Settings copy is +not application-authorable — `settings` is keyed by `SettingsManifest.namespace` +and only platform code declares a manifest. `settingsCommon` is **not** affected: +the Settings UI shell strings (the source badges, under +`settingsCommon.sourceLabels`) stay on both application faces. +Run `os migrate meta --from 17` to list the mechanical edits for existing +sources; apply them by hand. + +### Rows already stored are converted, not refused + +A `translation` row saved before this change keeps loading. The runtime +translation sync (`@objectstack/core`'s `authored-translation-sync`) reads +`sys_metadata` itself and used to merge the RAW stored payload; it now replays +the ADR-0087 conversion chain over each row before merging it, the same policy +as every other stored-metadata read seam. `translation-per-app-settings-removed` +has learned the item shape, so a stored row's `settings` is dropped there, the +rest of the item (`objects`, `apps`, …) still loads, and the server logs one +warning per row naming the row, the group and the conversion. Run +`os migrate meta --stored --apply` to persist the canonical rows. + +### What changes on screen, which is not nothing + +On the item door the group was STRONGER than on the bundle door. A published +item is loaded into the runtime-authored layer, which both i18n adapters read +**over** the shipped bundles — so an item's `settings` overrode the platform's +own Settings copy for its locale, rather than only filling gaps. After +upgrading, re-read the Settings screens in each locale such an item covered: +where it overrode a platform string, **the platform's string renders again**; +where it filled a gap the platform bundle leaves, the **manifest's own literal +renders, which is English**. If a platform string is wrong or missing for your +locale, correct it in the platform bundle (`@objectstack/service-settings`'s +`settingsBuiltinTranslations`). + +No deprecation window: the item door refuses the key by name from this major. + +### Unchanged + +The platform face — `PlatformTranslationDataSchema`, `settingsBuiltinTranslations`, +and `GET /api/v1/i18n/translations/:locale`, whose served document is the merged +tree — still declares `settings`. The liveness ledger's `translation.settings` +row is deleted because the key left the ITEM's shape; the platform capability it +evidenced is untouched. + +Ruling batch #210 item 2 letter B (2026-09-22) — maintainer 「210 同意」. + + diff --git a/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts b/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts index d6111c1fba7..f7b230b80e8 100644 --- a/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts +++ b/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts @@ -313,3 +313,59 @@ describe('[#10888 · GUARD] a face that carries no `issues[]` keeps the whole se expect(unknown.message).toContain(PRESCRIPTION); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. #19620 — the `translation` item door refuses the platform-only `settings` +// ═══════════════════════════════════════════════════════════════════════════ +// +// Ruling batch #210 item 2 letter B: `settings` left `TranslationItemSchema`, +// so a `translation` item carrying it is refused at THIS gate — the metadata +// door Studio, the metadata API and an AI agent write through — with the ADR-0112 +// envelope (`code` + `status`) and the platform-only prescription on the issue. +// It used to be accepted, and a published item's `settings` overrode the +// platform's own Settings copy (the runtime-authored layer is read over the +// shipped bundles). Rides this file's pinned engine double rather than a new +// one, since the gate under test is the same `saveMetaItem` refusal. + +async function saveTranslation(protocol: any, item: Record): Promise { + try { + return await protocol.saveMetaItem({ + type: 'translation', + name: 'zh_cn', + item, + writeFace: 'meta-envelope', + }); + } catch (e: any) { + return e; + } +} + +describe('[#19620] a `translation` item carrying `settings` is refused at the metadata door', () => { + const settings = { mail: { title: '邮件', keys: { host: { label: '主机' } } } }; + + it.each(['settings', 'setting'])('`%s` — 422 INVALID_METADATA, platform-only prescription, nothing stored', async (key) => { + const { protocol, rows } = makeProtocol(); + const err = await saveTranslation(protocol, { locale: 'zh-CN', [key]: settings }); + + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('INVALID_METADATA'); + expect(err.status).toBe(422); + const issue = (err.issues as Array<{ code?: string; message: string }>) + .find((i) => i.code === 'unrecognized_keys'); + expect(issue?.message).toContain(`\`${key}\``); + expect(issue?.message).toContain('PLATFORM group'); + expect(rows.size).toBe(0); + }); + + it('CONTROL — the same item without `settings` is stored (the refusal is the key, not the item)', async () => { + const { protocol, rows } = makeProtocol(); + const result = await saveTranslation(protocol, { + locale: 'zh-CN', + settingsCommon: { sourceLabels: { tenant: '租户' } }, + apps: { crm: { label: '客户关系管理' } }, + }); + + expect(result).not.toBeInstanceOf(Error); + expect([...rows.values()].map((r) => r.type)).toEqual(['translation']); + }); +}); diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index 09c6f1f7bdb..921eaf1bfe8 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -137,7 +137,6 @@ "translation/metadataForms", "translation/objects", "translation/pages", - "translation/settings", "translation/settingsCommon", "view/form.buttons", "view/form.groups", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index c8b9654cf96..02a8af71620 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7152,7 +7152,11 @@ const elementFormRemoved: MetadataConversion = { * replays this chain over each stored row before merging it * (`applyConversionsToStoredItem`), so a row written before the item door * closed stops overriding at the next sync, with this entry's notice logged, - * rather than at the next re-save. + * rather than at the next re-save. That needed the stored pass itself to + * reach `translation` rows at all — it had no collection for the type and + * returned every one untouched (`STORED_ONLY_COLLECTIONS` in `./stored.ts`) — + * which also puts the metadata API's reads and `os migrate meta --stored` + * on this entry. * * Two shapes, told apart structurally rather than by key spelling: * diff --git a/packages/spec/src/conversions/stored.test.ts b/packages/spec/src/conversions/stored.test.ts index 2375698cd8f..4e4c6ed057e 100644 --- a/packages/spec/src/conversions/stored.test.ts +++ b/packages/spec/src/conversions/stored.test.ts @@ -168,6 +168,34 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows, #3903)', () => }); }); + // #19620 — `translation` has no manifest-collection spelling, so until this + // the stored pass returned every translation row untouched and no seam ever + // replayed a translation conversion over one. The item door's `settings` is + // the case that made it matter: a stored item's copy overrides the + // platform's, because the runtime-authored layer is read over the bundles. + describe('stored translation rows (translation-per-app-settings-removed, #19620)', () => { + const storedItem = () => ({ + name: 'zh-CN', + locale: 'zh-CN', + settings: { mail: { title: '邮件' } }, + apps: { crm: { label: '客户关系管理' } }, + }); + + it.each(['translation', 'translations'])('drops `settings` from a stored `%s` row, loudly, keeping the rest', (type) => { + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem(type, storedItem(), { onNotice: (n) => notices.push(n) }); + expect(out).toEqual({ name: 'zh-CN', locale: 'zh-CN', apps: { crm: { label: '客户关系管理' } } }); + expect(notices.map((n) => [n.conversionId, n.from, n.to])).toEqual([ + ['translation-per-app-settings-removed', 'settings', '(removed)'], + ]); + }); + + it('CONTROL — a canonical translation row passes through by reference', () => { + const row = { name: 'zh-CN', locale: 'zh-CN', apps: { crm: { label: '客户关系管理' } } }; + expect(applyConversionsToStoredItem('translation', row)).toBe(row); + }); + }); + it('threads the conflict guard context through (flow callers that own a registry)', () => { const flow = { name: 'notify_flow', diff --git a/packages/spec/src/conversions/stored.ts b/packages/spec/src/conversions/stored.ts index d2a13255e04..50bc697ed45 100644 --- a/packages/spec/src/conversions/stored.ts +++ b/packages/spec/src/conversions/stored.ts @@ -37,6 +37,31 @@ import { applyConversions, type ApplyConversionsOptions } from './apply.js'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '../shared/metadata-collection.zod.js'; +/** + * Stored row types whose conversions walk a stack collection the + * MANIFEST-COLLECTION maps deliberately do not carry (#19620). + * + * `translation` is the one: `PLURAL_TO_SINGULAR` has no `translations` + * spelling because a stack's `translations` entries are locale-keyed BUNDLES, + * not named metadata items (`manifest-collection-spelling.ts` says why, and + * `check:stack-collection-maps` holds that map to the stack schema). But the + * translation conversions walk exactly that collection and are written for + * BOTH shapes in it — a bundle entry and a bare `translation` item, the shape a + * stored row is. Without this entry the stored pass had no collection to wrap + * a `translation` row in and returned it untouched, so no rehydration seam + * ever replayed a translation conversion over a stored row — which is how an + * item's `settings`, taken off the item door by #19620, would have gone on + * reaching the runtime from rows stored before. Kept HERE rather than added to + * the shared maps, which would advertise `translations` as a named-item stack + * collection to every other reader of them. + */ +const STORED_ONLY_COLLECTIONS: Readonly> = { + translation: 'translations', + // The legacy plural row spelling, as the protocol's singular/plural read + // fallback still finds it. + translations: 'translations', +}; + /** * Options for {@link applyConversionsToStoredItem} — everything * {@link ApplyConversionsOptions} offers except `includeRetired`, which this @@ -68,7 +93,7 @@ export function applyConversionsToStoredItem( ): T { if (item == null || typeof item !== 'object' || Array.isArray(item)) return item; const singular = PLURAL_TO_SINGULAR[type] ?? type; - const collection = SINGULAR_TO_PLURAL[singular]; + const collection = SINGULAR_TO_PLURAL[singular] ?? STORED_ONLY_COLLECTIONS[singular]; if (!collection) return item; const converted = applyConversions( { [collection]: [item as Record] }, From b0f2b0ef060f36c3750cb4d709632f4732214a1d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 01:20:47 +0000 Subject: [PATCH 5/5] chore(changeset): ADR-0087 disposition names the already-registered entries this change extends Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- .changeset/19620-translation-item-settings-platform-only.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/19620-translation-item-settings-platform-only.md b/.changeset/19620-translation-item-settings-platform-only.md index dcae7e344ad..4c3126eed2f 100644 --- a/.changeset/19620-translation-item-settings-platform-only.md +++ b/.changeset/19620-translation-item-settings-platform-only.md @@ -68,4 +68,4 @@ evidenced is untouched. Ruling batch #210 item 2 letter B (2026-09-22) — maintainer 「210 同意」. - +