From 1658c4caf51299aa30a3f1d4f9bb925901df51af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:38:52 +0000 Subject: [PATCH 01/14] fix(i18n): serve the translation bundle without Studio's server-resolved metadataForms GET /i18n/translations/:locale now answers the platform groups without SERVER_RESOLVED_TRANSLATION_GROUPS (metadataForms). Studio's authoring forms are already translated on the server by /meta/types, from the same i18n service tree, which the door copies and never edits. GetTranslationsResponseSchema declares the served face (ServedTranslationDataSchema), closed, so a body that still carries the group fails it. Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- .../i18n-translations-served-groups.test.ts | 237 ++++++++++++++++++ packages/runtime/src/domains/i18n.ts | 37 ++- packages/spec/src/api/protocol.zod.ts | 10 +- packages/spec/src/system/translation.zod.ts | 52 ++++ 4 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 packages/runtime/src/domains/i18n-translations-served-groups.test.ts diff --git a/packages/runtime/src/domains/i18n-translations-served-groups.test.ts b/packages/runtime/src/domains/i18n-translations-served-groups.test.ts new file mode 100644 index 00000000000..877f54a29a5 --- /dev/null +++ b/packages/runtime/src/domains/i18n-translations-served-groups.test.ts @@ -0,0 +1,237 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25785 — the translation bundle a console open downloads leaves out + * Studio's `metadataForms`, and Studio's forms stay translated. + * + * ## The defect + * + * `GET /i18n/translations/:locale` served the i18n service's whole locale tree, + * `metadataForms` included: 75 KB of the 221 KB raw a console open downloaded + * on `examples/app-crm` zh-CN, the second-largest group after `objects`. No + * browser reads it there. Studio's authoring forms arrive already translated: + * the `/meta` (`/meta/types`) handler resolves `metadataForms.` on the + * server, over the same service, before it serves each form. + * + * ## What is pinned + * + * - A: both exits (the requested locale and the `zh` → `zh-CN` fallback) and + * both spellings serve every group but the server-resolved ones, unchanged, + * and the body is the closed `GetTranslationsResponseSchema`. + * - B: the service's own tree is never edited, and `/meta`, read over the same + * service AFTER a boot read, still serves Studio's form translated. That is + * the consumer an in-place delete would silently break. + * - C: the validator still hashes the bytes served, so a warm reopen is still + * `304`, and a change confined to a group that is not served does not move it. + */ + +import { createHash } from 'node:crypto'; +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from '@objectstack/rest'; +import { GetTranslationsResponseSchema } from '@objectstack/spec/api'; +import { SERVER_RESOLVED_TRANSLATION_GROUPS } from '@objectstack/spec/system'; + +import { handleI18nRequest } from './i18n.js'; +import { apiErrorResponse } from '../error-envelope.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +const MEMBER_EC = { userId: 'usr_member', isSystem: false, positions: [], permissions: [], systemPermissions: [] }; + +function member(headers: Record = {}): HttpProtocolContext { + return { request: { headers }, executionContext: { ...MEMBER_EC } } as unknown as HttpProtocolContext; +} + +type Bundles = Record>; + +/** Hands back the STORED tree, as the core fallback and service-i18n both do. */ +function makeService(bundles: Bundles) { + return { + getLocales: vi.fn(() => Object.keys(bundles)), + getDefaultLocale: vi.fn(() => 'en'), + getFallbackLocale: vi.fn(() => 'en'), + getTranslations: vi.fn((locale: string) => bundles[locale] ?? {}), + }; +} + +function makeDeps(service: ReturnType): DomainHandlerDeps { + return { + getService: vi.fn(async () => service), + resolveService: vi.fn(async () => service), + success: (data: any, meta?: any) => ({ status: 200, body: { success: true, data, meta } }), + error: (message: string, httpStatus = 500, details?: any) => + apiErrorResponse({ message, httpStatus, details }), + } as unknown as DomainHandlerDeps; +} + +async function read( + service: ReturnType, + subPath = '/translations/zh-CN', + query: Record = {}, + context: HttpProtocolContext = member(), +): Promise { + return handleI18nRequest(makeDeps(service), subPath, 'GET', query, context); +} + +function validatorOf(body: unknown): string { + return `W/"${createHash('sha256').update(JSON.stringify(body)).digest('base64url')}"`; +} + +/** The groups a console reads, as the platform bundle carries them. */ +const SERVED_GROUPS = { + objects: { lead: { label: '潜在客户', fields: { company: { label: '公司' } } } }, + apps: { crm: { label: '客户关系管理' } }, + settings: { mail: { title: '邮件投递' } }, + messages: { save: '保存' }, +}; + +/** Studio's dashboard authoring form, translated the way platform-objects ships it. */ +const METADATA_FORMS = { + dashboard: { + label: '仪表板', + sections: { layout: { label: '布局' } }, + fields: { columns: { label: '列数', helpText: '栅格列数' } }, + }, +}; + +function zhTree(): Record { + return { ...SERVED_GROUPS, metadataForms: METADATA_FORMS }; +} + +describe('#25785 A — the served document leaves the server-resolved groups out', () => { + it('names metadataForms as a server-resolved group (the constant this door reads)', () => { + expect(SERVER_RESOLVED_TRANSLATION_GROUPS).toContain('metadataForms'); + }); + + it.each([ + ['the path spelling', '/translations/zh-CN', {}], + ['the query spelling', '/translations', { locale: 'zh-CN' }], + ])('%s: every other group is served unchanged, metadataForms is not', async (_label, subPath, query) => { + const result = await read(makeService({ en: {}, 'zh-CN': zhTree() }), subPath, query); + expect(result.response.status).toBe(200); + expect(result.response.body.data).toEqual({ locale: 'zh-CN', translations: SERVED_GROUPS }); + expect(result.response.body.data.translations).not.toHaveProperty('metadataForms'); + }); + + it('the locale fallback exit (zh → zh-CN) leaves it out too', async () => { + const result = await read(makeService({ en: {}, 'zh-CN': zhTree() }), '/translations/zh'); + expect(result.response.status).toBe(200); + expect(result.response.body.data).toEqual({ locale: 'zh-CN', requestedLocale: 'zh', translations: SERVED_GROUPS }); + }); + + it('the body satisfies the closed GetTranslationsResponseSchema, which refuses the unserved group', async () => { + const result = await read(makeService({ en: {}, 'zh-CN': zhTree() })); + expect(GetTranslationsResponseSchema.safeParse(result.response.body.data).success).toBe(true); + + // Control: the same document WITH the group is refused by the schema, so + // the parse above passes because the door left it out, not because the + // schema reads nothing. + const unserved = GetTranslationsResponseSchema.safeParse({ locale: 'zh-CN', translations: zhTree() }); + expect(unserved.success).toBe(false); + expect(unserved.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); + + it('a tree with nothing to leave out is served as it is', async () => { + const result = await read(makeService({ en: {}, 'zh-CN': { ...SERVED_GROUPS } })); + expect(result.response.body.data.translations).toEqual(SERVED_GROUPS); + }); +}); + +// ── B: the service's tree, and the consumer that reads it ─────────────────── + +function mockRes() { + return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() }; +} + +/** A RestServer whose `/meta` handler translates from `i18n` — the route Studio reads its forms from. */ +function restOver(i18n: ReturnType): RestServer { + const server = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; + const protocol = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn(async () => ({ + entries: [{ + type: 'dashboard', + label: 'Dashboard', + form: { + schemaId: 'dashboard', + type: 'simple', + sections: [{ name: 'layout', label: 'Layout', fields: [{ field: 'columns', type: 'number', label: 'Columns' }] }], + }, + }], + types: ['dashboard'], + registered: ['dashboard'], + })), + getMetaItems: vi.fn(async () => []), + getMetaItem: vi.fn(async () => undefined), + getMetaItemCached: undefined as any, + findData: vi.fn().mockResolvedValue([]), + }; + const rest = new RestServer( + server as any, protocol as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + async () => i18n, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + return rest; +} + +async function readStudioForm(rest: RestServer, locale: string): Promise { + const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta'); + if (!route) throw new Error('route not registered: GET /api/v1/meta'); + const res = mockRes(); + await route.handler({ method: 'GET', params: {}, query: {}, body: {}, headers: { 'accept-language': locale } }, res); + const body = res.json.mock.calls.at(-1)?.[0]; + return body.entries.find((e: any) => e.type === 'dashboard'); +} + +describe("#25785 B — the service keeps the group, and Studio's forms stay translated", () => { + it("a boot read does not edit the service's tree", async () => { + const stored = zhTree(); + const service = makeService({ en: {}, 'zh-CN': stored }); + await read(service); + expect(stored.metadataForms).toBe(METADATA_FORMS); + expect(service.getTranslations('zh-CN')).toHaveProperty('metadataForms', METADATA_FORMS); + }); + + it('/meta, read over the same service after a boot read, serves the form translated', async () => { + const service = makeService({ en: {}, 'zh-CN': zhTree() }); + const boot = await read(service); + expect(boot.response.body.data.translations).not.toHaveProperty('metadataForms'); + + const entry = await readStudioForm(restOver(service), 'zh-CN'); + expect(entry.label).toBe('仪表板'); + expect(entry.form.sections[0].label).toBe('布局'); + expect(entry.form.sections[0].fields[0].label).toBe('列数'); + }); +}); + +// ── C: the validator, over the bytes now served ───────────────────────────── + +describe('#25785 C — a warm reopen is still answered 304', () => { + it('the validator hashes the served body, and naming it answers 304', async () => { + const service = makeService({ en: {}, 'zh-CN': zhTree() }); + const first = await read(service); + expect(first.response.headers.ETag).toBe(validatorOf(first.response.body)); + + const again = await read(service, '/translations/zh-CN', {}, member({ 'if-none-match': first.response.headers.ETag })); + expect(again.response.status).toBe(304); + expect(again.response.body).toBeUndefined(); + }); + + it('a change confined to an unserved group serves the same bytes, so the validator holds', async () => { + const bundles: Bundles = { en: {}, 'zh-CN': zhTree() }; + const service = makeService(bundles); + const before = (await read(service)).response.headers.ETag; + + bundles['zh-CN'] = { ...SERVED_GROUPS, metadataForms: { dashboard: { label: '看板' } } }; + expect((await read(service)).response.headers.ETag).toBe(before); + + bundles['zh-CN'] = { ...SERVED_GROUPS, messages: { save: '存储' }, metadataForms: METADATA_FORMS }; + expect((await read(service)).response.headers.ETag).not.toBe(before); + }); +}); diff --git a/packages/runtime/src/domains/i18n.ts b/packages/runtime/src/domains/i18n.ts index bb41d094d8c..3154a44df51 100644 --- a/packages/runtime/src/domains/i18n.ts +++ b/packages/runtime/src/domains/i18n.ts @@ -26,7 +26,8 @@ * The translation bundle is the one face a browser revalidates: it carries an * `ETag` and `Cache-Control: private, no-cache`, and a matching * `If-None-Match` is answered `304` with no body (#25785, see - * {@link translationsAnswer}). + * {@link translationsAnswer}). It is served without the groups the server + * resolves itself, Studio's `metadataForms` (see {@link servedTranslations}). */ import { createHash } from 'node:crypto'; @@ -34,7 +35,9 @@ import { resolveLocale, shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; -import { CoreServiceName, resolveObjectFieldLabels, toLocaleDescriptors } from '@objectstack/spec/system'; +import { + CoreServiceName, SERVER_RESOLVED_TRANSLATION_GROUPS, resolveObjectFieldLabels, toLocaleDescriptors, +} from '@objectstack/spec/system'; import { isServiceServeable } from '../service-serveable.js'; import type { TranslationData } from '@objectstack/spec/system'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -108,6 +111,28 @@ function ifNoneMatchHits(header: string | undefined, etag: string): boolean { }); } +/** + * [#25785] The document a translations read serves: the locale tree the i18n + * service holds, without `SERVER_RESOLVED_TRANSLATION_GROUPS`, the groups the + * server resolves before it serves what they translate. `metadataForms` is the + * one today: `/meta/types` answers Studio's authoring forms already translated + * from it, and no browser reads it here. `GetTranslationsResponseSchema` + * declares the served face without it. + * + * ⛔ A COPY, never an edit of `translations`. `getTranslations` hands back the + * service's own tree (the core fallback and service-i18n both return the stored + * object), and that tree is the one `/meta/types` translates from: deleting the + * group in place would leave every later Studio form in English. + */ +function servedTranslations(translations: Record): Record { + const resolvedOnServer: readonly string[] = SERVER_RESOLVED_TRANSLATION_GROUPS; + const served: Record = {}; + for (const [group, value] of Object.entries(translations)) { + if (!resolvedOnServer.includes(group)) served[group] = value; + } + return served; +} + /** * [#25785] Answer a translations read with its validator. Every console open * read the whole locale bundle again (80–88 KB compressed on a hosted tenant) @@ -239,11 +264,15 @@ export async function handleI18nRequest( const resolved = resolveLocale(locale, availableLocales); if (resolved && resolved !== locale) { translations = i18nService.getTranslations(resolved); - return translationsAnswer(deps, { locale: resolved, requestedLocale: locale, translations }, context); + return translationsAnswer( + deps, + { locale: resolved, requestedLocale: locale, translations: servedTranslations(translations) }, + context, + ); } } - return translationsAnswer(deps, { locale, translations }, context); + return translationsAnswer(deps, { locale, translations: servedTranslations(translations) }, context); } // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index a9e52b2a7b8..1ec615cbc36 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -28,7 +28,7 @@ import { import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod'; import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod'; import { ActionDescriptorSchema } from '../automation/node-executor.zod'; -import { PlatformTranslationDataSchema } from '../system/translation.zod'; +import { ServedTranslationDataSchema } from '../system/translation.zod'; // #5950 / #5882 — the ADR-0010 read-side protection envelope both metadata-item // responses publish. Same three vocabularies the resolver filters against, so a // value this spec cannot name is a value the resolver would have dropped. @@ -3705,10 +3705,16 @@ export const GetTranslationsRequestSchema = lazySchema(() => z.object({ * `stack.translations` — so it carries the platform-only `settings` group and * is typed against the platform face, not the per-app one * (`system/translation.zod.ts`). + * + * Less the groups the server resolves itself (`SERVER_RESOLVED_TRANSLATION_GROUPS`, + * #25785): `metadataForms` stays in the i18n service, where `/meta/types` + * translates Studio's authoring forms from it, and is not part of this answer. + * `translations` is therefore {@link ServedTranslationDataSchema}, the platform + * face without those groups, closed so a body that still carries one fails it. */ export const GetTranslationsResponseSchema = lazySchema(() => z.object({ locale: z.string().describe('Locale code'), - translations: PlatformTranslationDataSchema.describe('Translation data'), + translations: ServedTranslationDataSchema.describe('Translation data'), })); export const GetFieldLabelsRequestSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index c14a149ddfd..ed6804cf2d6 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -2009,6 +2009,58 @@ export const PlatformTranslationDataSchema = lazySchema(() => strictObject({ export type PlatformTranslationData = z.input; +/** + * The platform groups the SERVED document leaves out — + * `GET /i18n/translations/:locale` answers {@link ServedTranslationDataSchema}, + * the platform face without these (#25785). + * + * A group belongs here when the server resolves it before it serves the + * surface the group translates, so no browser reads it from the served + * document. `metadataForms` is the one member: it translates Studio's + * metadata-type authoring forms, and those reach Studio already translated — + * the `/meta/types` handler applies `resolveMetadataTypeLabel`, + * `resolveMetadataFormLabels` and `resolveMetadataFormSchemaTitles` to each + * entry over the i18n service's in-process bundle before it answers. Carried in + * the served document as well, it was the second-largest group of every console + * open (75 KB of 221 KB raw on `examples/app-crm` zh-CN) and nothing read it. + * + * ⛔ Only the served document leaves these out. Every bundle face still + * declares the group, and the i18n service still holds it: the serving door + * copies the document without them and never edits the service's own tree, + * which is the tree `/meta/types` translates from. + */ +export const SERVER_RESOLVED_TRANSLATION_GROUPS = ['metadataForms'] as const; + +/** + * One locale of the SERVED translation document — what + * `GET /i18n/translations/:locale` answers: {@link PlatformTranslationDataSchema} + * without {@link SERVER_RESOLVED_TRANSLATION_GROUPS}. + * + * Its own `strictObject` rather than `.omit()` on the platform face, for the + * reason {@link appTranslationDataShape} gives against `.extend()`: the derived + * schema would keep the error map that closed over the base's keys, so a body + * still carrying `metadataForms` would be told the key it should not carry is + * a declared one. Closed, a served body that carries a server-resolved group + * fails this schema, which is what the response conformance suites parse with. + */ +export const ServedTranslationDataSchema = lazySchema(() => { + const { metadataForms: _serverResolved, ...appGroups } = appTranslationDataShape(); + return strictObject({ + surface: 'this locale of the served translation document', + history: TRANSLATION_HISTORY, + guidance: { + metadataForms: + "the served document leaves 'metadataForms' out: Studio's authoring forms are translated " + + 'on the server, and /meta/types serves each form already in the requested locale.', + }, + }, { + ...appGroups, + ...platformSettingsShape(), + }).describe('Served translation data — the platform groups the browser reads, without the server-resolved `metadataForms`'); +}); + +export type ServedTranslationData = z.input; + // ──────────────────────────────────────────────────────────────────────────── // Translation Bundle (all locales) // ──────────────────────────────────────────────────────────────────────────── From 143499a32a90f3b45455134de303669c6f418449 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:40:04 +0000 Subject: [PATCH 02/14] test(spec): pin the served translation face against the platform face and the response schema Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- .../system/served-translation-data.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 packages/spec/src/system/served-translation-data.test.ts diff --git a/packages/spec/src/system/served-translation-data.test.ts b/packages/spec/src/system/served-translation-data.test.ts new file mode 100644 index 00000000000..537f859cea6 --- /dev/null +++ b/packages/spec/src/system/served-translation-data.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25785 — the served translation document is the platform face without the + * groups the server resolves itself. + * + * `GET /i18n/translations/:locale` answers `GetTranslationsResponseSchema`, + * whose `translations` is {@link ServedTranslationDataSchema}. The runtime door + * leaves out {@link SERVER_RESOLVED_TRANSLATION_GROUPS} (`metadataForms`, which + * `/meta/types` resolves into Studio's forms on the server), and these pins keep + * the constant, the served face and the response schema one declaration: + * + * - the served face is exactly the platform face minus the constant, both ways; + * - a server-resolved group stays authorable on both bundle faces (only the + * served document leaves it out); + * - the served face is closed: a body still carrying the group is refused by + * name, which is what lets a response conformance suite catch a door that + * stops leaving it out. + */ + +import { describe, it, expect } from 'vitest'; +import { GetTranslationsResponseSchema } from '../api/protocol.zod'; +import { + PlatformTranslationDataSchema, + SERVER_RESOLVED_TRANSLATION_GROUPS, + ServedTranslationDataSchema, + TranslationDataSchema, +} from './translation.zod'; + +const keysOf = (schema: { shape: Record }) => Object.keys(schema.shape).sort(); + +describe('#25785 — the served translation face', () => { + it('is the platform face without SERVER_RESOLVED_TRANSLATION_GROUPS, in both directions', () => { + const platform = keysOf(PlatformTranslationDataSchema); + const expected = platform.filter((k) => !(SERVER_RESOLVED_TRANSLATION_GROUPS as readonly string[]).includes(k)); + // Positive control: the walk read real shapes, and the constant names a platform group. + expect(platform).toEqual(expect.arrayContaining(['objects', 'settings', 'metadataForms'])); + expect(expected.length).toBe(platform.length - SERVER_RESOLVED_TRANSLATION_GROUPS.length); + expect(keysOf(ServedTranslationDataSchema)).toEqual(expected); + }); + + it('is what GetTranslationsResponseSchema serves under `translations`', () => { + expect(keysOf(GetTranslationsResponseSchema.shape.translations)).toEqual(keysOf(ServedTranslationDataSchema)); + }); + + it.each(SERVER_RESOLVED_TRANSLATION_GROUPS)('%s stays authorable on both bundle faces', (group) => { + expect(Object.keys(TranslationDataSchema.shape)).toContain(group); + expect(Object.keys(PlatformTranslationDataSchema.shape)).toContain(group); + }); + + it('accepts a document carrying the served groups', () => { + const served = { + objects: { lead: { label: '潜在客户', fields: { company: { label: '公司' } } } }, + settings: { mail: { title: '邮件投递' } }, + messages: { save: '保存' }, + }; + expect(ServedTranslationDataSchema.safeParse(served).success).toBe(true); + expect(GetTranslationsResponseSchema.safeParse({ locale: 'zh-CN', translations: served }).success).toBe(true); + }); + + it.each(SERVER_RESOLVED_TRANSLATION_GROUPS)('refuses a document still carrying %s, by name', (group) => { + const result = ServedTranslationDataSchema.safeParse({ messages: { save: '保存' }, [group]: {} }); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue).toBeDefined(); + expect((issue as { keys?: string[] }).keys).toEqual([group]); + + const response = GetTranslationsResponseSchema.safeParse({ locale: 'zh-CN', translations: { [group]: {} } }); + expect(response.success).toBe(false); + }); +}); From e2fc71e0745138abdbb4223effcb47073562c121 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:49:40 +0000 Subject: [PATCH 03/14] chore(spec): regenerate artifacts for the served translation face Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 1 - content/docs/references/index.mdx | 10 +- .../docs/references/system/translation.mdx | 151 +++++++++++++++++- .../system.md | 2 +- packages/spec/api-surface/system.json | 3 + packages/spec/authorable-surface/system.json | 14 ++ packages/spec/declaration-map/system.json | 2 + .../spec/dropped-refinements.baseline.json | 7 + packages/spec/export-origins/system.json | 3 + .../spec/json-schema.manifest/system.json | 1 + .../src/type-alias-convention.pin.test.ts | 16 +- 11 files changed, 198 insertions(+), 12 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index db943b5a160..fcb72299250 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1782,7 +1782,6 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set 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 | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 1767273fd9c..70638a97fe6 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1544 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1545 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 5 | 34 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 37 | 286 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1544** | 14 protocol modules | +| **Total** | **199** | **1545** | 14 protocol modules | --- @@ -318,7 +318,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 286 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 287 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -359,7 +359,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`supplier-security.zod.ts`](/docs/references/system/supplier-security) | `SupplierAssessmentStatus`, `SupplierRiskLevel`, `SupplierSecurityAssessment`, `SupplierSecurityPolicy`, `SupplierSecurityRequirement` | | [`tenant.zod.ts`](/docs/references/system/tenant) | `DatabaseLevelIsolationStrategy`, `DatabaseProvider`, `QuotaEnforcementResult`, `RowLevelIsolationStrategy`, `SchemaLevelIsolationStrategy`, `Tenant`, `TenantConnectionConfig`, `TenantIsolationConfig`, `TenantIsolationLevel`, `TenantQuota`, `TenantSecurityPolicy`, `TenantUsage` | | [`tracing.zod.ts`](/docs/references/system/tracing) | `OpenTelemetryCompatibility`, `OtelExporterType`, `SamplingDecision`, `SamplingStrategyType`, `Span`, `SpanAttributeValue`, `SpanAttributes`, `SpanEvent`, `SpanKind`, `SpanLink`, `SpanStatus`, `TraceContext`, `TraceContextPropagation`, `TraceFlags`, `TracePropagationFormat`, `TraceSamplingConfig`, `TraceState`, `TracingConfig` | -| [`translation.zod.ts`](/docs/references/system/translation) | `ActionResultDialogTranslation`, `CoverageBreakdownEntry`, `FieldTranslation`, `Locale`, `ObjectTranslationData`, `PlatformTranslationBundle`, `PlatformTranslationData`, `TranslationBundle`, `TranslationConfig`, `TranslationCoverageResult`, `TranslationData`, `TranslationDiffItem`, `TranslationDiffStatus`, `TranslationItem` | +| [`translation.zod.ts`](/docs/references/system/translation) | `ActionResultDialogTranslation`, `CoverageBreakdownEntry`, `FieldTranslation`, `Locale`, `ObjectTranslationData`, `PlatformTranslationBundle`, `PlatformTranslationData`, `ServedTranslationData`, `TranslationBundle`, `TranslationConfig`, `TranslationCoverageResult`, `TranslationData`, `TranslationDiffItem`, `TranslationDiffStatus`, `TranslationItem` | | [`worker.zod.ts`](/docs/references/system/worker) | `BatchProgress`, `QueueConfig`, `Task`, `TaskExecutionResult`, `TaskPriority`, `TaskRetryPolicy`, `TaskStatus`, `WorkerStats` | --- diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 644d112e4c7..d16708d3bf6 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -13,8 +13,8 @@ description: "Translation schemas of the ObjectStack System Protocol — each pr ## TypeScript Usage ```typescript -import { ActionResultDialogTranslationSchema, CoverageBreakdownEntrySchema, FieldTranslationSchema, LocaleSchema, ObjectTranslationDataSchema, PlatformTranslationBundleSchema, PlatformTranslationDataSchema, TranslationBundleSchema, TranslationConfigSchema, TranslationCoverageResultSchema, TranslationDataSchema, TranslationDiffItemSchema, TranslationDiffStatusSchema, TranslationItemSchema } from '@objectstack/spec/system'; -import type { ActionResultDialogTranslation, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, PlatformTranslationBundle, PlatformTranslationData, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationItem } from '@objectstack/spec/system'; +import { ActionResultDialogTranslationSchema, CoverageBreakdownEntrySchema, FieldTranslationSchema, LocaleSchema, ObjectTranslationDataSchema, PlatformTranslationBundleSchema, PlatformTranslationDataSchema, ServedTranslationDataSchema, TranslationBundleSchema, TranslationConfigSchema, TranslationCoverageResultSchema, TranslationDataSchema, TranslationDiffItemSchema, TranslationDiffStatusSchema, TranslationItemSchema } from '@objectstack/spec/system'; +import type { ActionResultDialogTranslation, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, PlatformTranslationBundle, PlatformTranslationData, ServedTranslationData, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationItem } from '@objectstack/spec/system'; // Validate data const result = ActionResultDialogTranslationSchema.parse(data); @@ -317,6 +317,153 @@ Translation data for a single object | **banners** | `Record` | optional | Info banner translations keyed by the info_banner specifier id | +--- + +## ServedTranslationData + +Served translation data — the platform groups the browser reads, without the server-resolved `metadataForms` + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objects** | `Record; … }>` | optional | Object translations keyed by object name | +| **picklists** | `Record }>` | optional | Picklist translations keyed by picklist name | +| **apps** | `Record }>` | optional | App translations keyed by app name | +| **messages** | `Record` | optional | UI message translations keyed by message ID | +| **globalActions** | `Record` | optional | Global action translations keyed by action name | +| **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | +| **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | +| **reports** | `Record` | optional | Report translations keyed by report name | +| **pages** | `Record` | optional | Page translations keyed by page name | +| **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **positions** | `Record` | optional | Position translations keyed by position name | +| **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | +| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | + +### Nested Shape: `ServedTranslationData.objects[string]` + +Translation data for a single object + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated singular label | +| **pluralLabel** | `string` | optional | Translated plural label | +| **description** | `string` | optional | Translated object description | +| **fields** | `Record }>` | optional | Field-level translations | +| **_views** | `Record` | optional | View translations keyed by view name | +| **_actions** | `Record` | optional | Action translations keyed by action name | +| **_sections** | `Record }>` | optional | Section translations keyed by section name | +| **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | +| **_validations** | `Record` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) | + +### Nested Shape: `ServedTranslationData.picklists[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated picklist label | +| **options** | `Record` | ✅ | Option value to translated label map — inherited by every field that references the picklist | + +### Nested Shape: `ServedTranslationData.apps[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | ✅ | Translated app label | +| **description** | `string` | optional | Translated app description | +| **navigation** | `Record` | optional | Navigation group translations keyed by group ID | + +### Nested Shape: `ServedTranslationData.globalActions[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated action label | +| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | +| **confirmText** | `string` | optional | Translated confirmation prompt | +| **successMessage** | `string` | optional | Translated success toast/message | +| **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | +| **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | +| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | + +### Nested Shape: `ServedTranslationData.dashboards[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated dashboard title | +| **description** | `string` | optional | Translated dashboard description | +| **actions** | `Record` | optional | Header action label translations keyed by action url/key | +| **widgets** | `Record` | optional | Widget translations keyed by widget id | +| **globalFilters** | `Record }>` | optional | Global-filter translations keyed by the filter `name` (a filter that authors no `name` is keyed by its `field`) | + +### Nested Shape: `ServedTranslationData.datasets[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated dataset label | +| **description** | `string` | optional | Translated dataset description | +| **dimensions** | `Record` | optional | Dimension translations keyed by dimension name (`DatasetDimensionSchema.name`) | +| **measures** | `Record` | optional | Measure translations keyed by measure name (`DatasetMeasureSchema.name`) | + +### Nested Shape: `ServedTranslationData.reports[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated report label — overlays the report's own `label` | +| **description** | `string` | optional | Translated report description — overlays the report's own `description` | + +### Nested Shape: `ServedTranslationData.pages[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated page label (nav / breadcrumb) | +| **description** | `string` | optional | Translated page description | +| **title** | `string` | optional | Translated `page:header` title. When omitted the header keeps its authored title; `label` stands in only where the header authors no title or its title restates the page label | +| **subtitle** | `string` | optional | Translated `page:header` subtitle | +| **components** | `Record` | optional | Per-component copy keyed by component id (`PageComponentSchema.id`) | + +### Nested Shape: `ServedTranslationData.flows[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated flow label | +| **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | +| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | +| **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | +| **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | +| **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | + +### Nested Shape: `ServedTranslationData.positions[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated position label — overlays the position's own `label` | +| **description** | `string` | optional | Translated position description — overlays the position's own `description` | + +### Nested Shape: `ServedTranslationData.permissions[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` | +| **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` | + +### Nested Shape: `ServedTranslationData.settingsCommon` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **sourceLabels** | `{ env?: string; global?: string; tenant?: string; user?: string; … }` | optional | Source badge labels by resolution layer | + +### Nested Shape: `ServedTranslationData.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 | +| **banners** | `Record` | optional | Info banner translations keyed by the info_banner specifier id | + + --- ## TranslationBundle diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index eeed4d62d38..9c535ae34bf 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 382 | +| `system/` | 383 | diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index a4ab8b99755..9297c68fbf7 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -581,6 +581,7 @@ "RowLevelIsolationStrategy (type)", "RowLevelIsolationStrategyParsed (type)", "RowLevelIsolationStrategySchema (const)", + "SERVER_RESOLVED_TRANSLATION_GROUPS (const)", "SETTINGS_CHANGE_EVENT (const)", "SamplingDecision (const)", "SamplingDecision (type)", @@ -609,6 +610,8 @@ "SecurityEventCorrelation (type)", "SecurityEventCorrelationParsed (type)", "SecurityEventCorrelationSchema (const)", + "ServedTranslationData (type)", + "ServedTranslationDataSchema (const)", "ServerRateLimitConfig (type)", "ServerRateLimitConfigParsed (type)", "ServerRateLimitConfigSchema (const)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index cf6f38d8607..7fd32ae69f6 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1029,6 +1029,20 @@ "system/SecurityEventCorrelation:linkAuthToAudit", "system/SecurityEventCorrelation:linkEncryptionToAudit", "system/SecurityEventCorrelation:linkMaskingToAudit", + "system/ServedTranslationData:apps", + "system/ServedTranslationData:dashboards", + "system/ServedTranslationData:datasets", + "system/ServedTranslationData:flows", + "system/ServedTranslationData:globalActions", + "system/ServedTranslationData:messages", + "system/ServedTranslationData:objects", + "system/ServedTranslationData:pages", + "system/ServedTranslationData:permissions", + "system/ServedTranslationData:picklists", + "system/ServedTranslationData:positions", + "system/ServedTranslationData:reports", + "system/ServedTranslationData:settings", + "system/ServedTranslationData:settingsCommon", "system/ServerRateLimitConfig:enabled", "system/ServerRateLimitConfig:maxRequests", "system/ServerRateLimitConfig:windowMs", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index 827500b073e..b68745bb36f 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -394,6 +394,8 @@ "SecurityContextConfigSchema": "system/SecurityContextConfig", "SecurityEventCorrelation": "system/SecurityEventCorrelation", "SecurityEventCorrelationSchema": "system/SecurityEventCorrelation", + "ServedTranslationData": "system/ServedTranslationData", + "ServedTranslationDataSchema": "system/ServedTranslationData", "ServerRateLimitConfig": "system/ServerRateLimitConfig", "ServerRateLimitConfigSchema": "system/ServerRateLimitConfig", "ServiceConfig": "system/ServiceConfig", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 23014d0a90e..796b1ddeefc 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -1347,6 +1347,13 @@ "options[0].timezone" ] }, + "system/ServedTranslationData": { + "sites": [ + "flows.valueType.refusals.valueType.message", + "flows.valueType.screens.valueType.description", + "flows.valueType.screens.valueType.title" + ] + }, "system/ServerRateLimitConfig": { "sites": [ "" diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index f7ed892e55c..935a29155b9 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -557,6 +557,7 @@ "RowLevelIsolationStrategy": "src/system/tenant.zod.ts#RowLevelIsolationStrategy (type)", "RowLevelIsolationStrategyParsed": "src/system/tenant.zod.ts#RowLevelIsolationStrategyParsed (type)", "RowLevelIsolationStrategySchema": "src/system/tenant.zod.ts#RowLevelIsolationStrategySchema (const)", + "SERVER_RESOLVED_TRANSLATION_GROUPS": "src/system/translation.zod.ts#SERVER_RESOLVED_TRANSLATION_GROUPS (const)", "SETTINGS_CHANGE_EVENT": "src/system/settings-client.zod.ts#SETTINGS_CHANGE_EVENT (const)", "SamplingDecision": "src/system/tracing.zod.ts#SamplingDecision (type)", "SamplingStrategyType": "src/system/tracing.zod.ts#SamplingStrategyType (type)", @@ -583,6 +584,8 @@ "SecurityEventCorrelation": "src/system/security-context.zod.ts#SecurityEventCorrelation (type)", "SecurityEventCorrelationParsed": "src/system/security-context.zod.ts#SecurityEventCorrelationParsed (type)", "SecurityEventCorrelationSchema": "src/system/security-context.zod.ts#SecurityEventCorrelationSchema (const)", + "ServedTranslationData": "src/system/translation.zod.ts#ServedTranslationData (type)", + "ServedTranslationDataSchema": "src/system/translation.zod.ts#ServedTranslationDataSchema (const)", "ServerRateLimitConfig": "src/system/stack-server.zod.ts#ServerRateLimitConfig (type)", "ServerRateLimitConfigParsed": "src/system/stack-server.zod.ts#ServerRateLimitConfigParsed (type)", "ServerRateLimitConfigSchema": "src/system/stack-server.zod.ts#ServerRateLimitConfigSchema (const)", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index b60b48513ea..1ab9724c7cd 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -215,6 +215,7 @@ "system/SearchProvider", "system/SecurityContextConfig", "system/SecurityEventCorrelation", + "system/ServedTranslationData", "system/ServerRateLimitConfig", "system/ServiceConfig", "system/ServiceCriticality", diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 40c6c20ac41..2f7201d4c59 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -280,7 +280,7 @@ import type * as M191 from './automation/api-trigger-secret-ref.zod.js'; import type * as M192 from './data/column-apply-on.zod.js'; // --------------------------------------------------------------------------- -// 789 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 790 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1435,6 +1435,9 @@ export type Iso_system_translation__LocaleSchema = Assert, z.infer< typeof M152.ObjectTranslationDataSchema > >>; export type Iso_system_translation__PlatformTranslationBundleSchema = Assert, z.infer< typeof M152.PlatformTranslationBundleSchema > >>; export type Iso_system_translation__PlatformTranslationDataSchema = Assert, z.infer< typeof M152.PlatformTranslationDataSchema > >>; +// #25785: the served face is the platform face less one `.optional()` group, so its +// two states coincide as the platform face's do. +export type Iso_system_translation__ServedTranslationDataSchema = Assert, z.infer< typeof M152.ServedTranslationDataSchema > >>; export type Iso_system_translation__TranslationBundleSchema = Assert, z.infer< typeof M152.TranslationBundleSchema > >>; export type Iso_system_translation__TranslationConfigSchema = Assert, z.infer< typeof M152.TranslationConfigSchema > >>; export type Iso_system_translation__TranslationCoverageResultSchema = Assert, z.infer< typeof M152.TranslationCoverageResultSchema > >>; @@ -1710,7 +1713,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 789 isomorphic pins', () => { + it('still declares all 790 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2600,7 +2603,14 @@ describe('ADR-0122 type-alias convention', () => { // (Iso_ui_dataset__DatasetMeasureDurationSchema, // Iso_ui_dataset__DatasetDurationUnit) rather than given `…Parsed` // synonyms. +2 added. - expect(pins).toHaveLength(789); + // + // 789 -> 790 is #25785: `ServedTranslationDataSchema` + // (`system/translation.zod.ts`, module slot M152), the served face of the + // translation document — the platform face less the server-resolved + // `metadataForms` group, so it gains no default or transform and is pinned + // here (Iso_system_translation__ServedTranslationDataSchema) beside its + // platform sibling rather than given a `…Parsed` synonym. +1 added. + expect(pins).toHaveLength(790); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From 84674c924c14a6de90e640e1a326e3eebc292dd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:50:44 +0000 Subject: [PATCH 04/14] docs(i18n): the served bundle leaves the server-resolved metadataForms out; changeset Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- ...-served-translations-without-metadata-forms.md | 15 +++++++++++++++ content/docs/protocol/kernel/i18n-standard.mdx | 12 +++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 .changeset/25785-served-translations-without-metadata-forms.md diff --git a/.changeset/25785-served-translations-without-metadata-forms.md b/.changeset/25785-served-translations-without-metadata-forms.md new file mode 100644 index 00000000000..d1dfcffbb5f --- /dev/null +++ b/.changeset/25785-served-translations-without-metadata-forms.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +'@objectstack/runtime': patch +--- + +fix(i18n): the translation bundle a console downloads leaves out Studio's `metadataForms` + +Clause-②: no +Metadata-protocol: none + +`GET /i18n/translations/:locale` served the i18n service's whole locale tree, so every console open downloaded `metadataForms`, the translations of Studio's metadata-type authoring forms: 75 KB of the 221 KB raw zh-CN bundle on `examples/app-crm`. No browser reads that group from the bundle. Studio's forms already arrive translated, because `GET /meta` (and `/meta/types`) resolves `metadataForms.` on the server, from the same i18n service, before it serves each form. + +The route now serves every group except the ones the server resolves itself. The spec names them in `SERVER_RESOLVED_TRANSLATION_GROUPS`, which today holds only `metadataForms`. `GetTranslationsResponseSchema.translations` is now `ServedTranslationDataSchema`: the platform face without those groups, and closed, so a body that still carries one fails it. The runtime copies the tree and never edits the service's own one, which is the tree `/meta` translates from. The `ETag` still hashes the bytes served, so a warm reopen still gets `304`. + +What to do: nothing, unless you read `metadataForms` from this route. Authoring is unchanged. `metadataForms` stays a group on every bundle face and in the i18n service. To show a metadata form in the reader's language, read it from `GET /meta/types`, which serves it already translated. diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index ed03d92ead8..7979b59f9d2 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -470,9 +470,15 @@ through the one the renderer reads for that node: Both run in `GET /api/v1/meta`, which serves every type's `form` and `schema` already localised for the request's locale; a client deriving the schema itself -with `z.toJSONSchema` applies the second one to its own copy. Only `label` -crosses over to the schema (as `title`); `helpText` and `placeholder` stay on -the form. +with `z.toJSONSchema` applies the second one to its own copy, over a bundle it +carries itself. Only `label` crosses over to the schema (as `title`); `helpText` +and `placeholder` stay on the form. + +Because the server resolves this group, the bundle a console downloads when it +opens — `GET /api/v1/i18n/translations/:locale` — leaves `metadataForms` out: +`SERVER_RESOLVED_TRANSLATION_GROUPS` names it, and the response's `translations` +is `ServedTranslationData`, the platform groups without it. Author it like any +other group; it stays in the i18n service, which is where `/meta` reads it. **Naming an item-level property** therefore has three parts, none of which is a locale-catalog entry alone: the English name is a `.meta({ title: 'Action URL' })` From 8b2ad27c24209acc9cddb61f4792b0b7e70a91e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 13:02:25 +0000 Subject: [PATCH 05/14] chore(spec): count the served face's row in the dropped-refinements header totals Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 796b1ddeefc..08976cb1c29 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 244, - "droppedRefinementSites": 838, + "publishedSchemasWithDroppedRefinements": 245, + "droppedRefinementSites": 841, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, From 2f4dfc026848dea2742fa28097ee6082a6968304 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:23:09 +0000 Subject: [PATCH 06/14] fix(i18n)!: declare requestedLocale and carry the breaking disposition for the served bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat's contract review of the served-translations PR amends the claim to `Clause-②: yes (narrowing)`: the published `GetTranslationsResponseSchema` accepted a body carrying `metadataForms` and now refuses it, and the exported type loses that member. The changeset carries the BREAKING banner, the remedy (read the group from `/meta/types`) and one ADR-0087 disposition. `requestedLocale` is declared on the same schema, optional. The runtime door has served it on the locale-fallback exit since that fallback existed, with no declaration behind it; pins in the spec and runtime suites now read it from the schema. Two ui docblocks described the old composition. Both now say what is true: `SPEC_TRANSLATION_GROUPS` is a superset of the groups the server still serves, and the metadata designer's overlay names `/meta/types` as the channel that carries an environment's own `metadataForms`. Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- ...ved-translations-without-metadata-forms.md | 12 ++++++++--- content/docs/references/api/protocol.mdx | 1 + .../i18n-translations-served-groups.test.ts | 6 +++++- packages/spec/authorable-surface/api.json | 1 + packages/spec/src/api/protocol.zod.ts | 10 ++++++++++ .../system/served-translation-data.test.ts | 12 +++++++++++ .../metadata-admin/metadata-form-i18n.ts | 12 +++++++++-- .../i18n/src/utils/spec-translations.ts | 20 ++++++++++++++----- 8 files changed, 63 insertions(+), 11 deletions(-) diff --git a/.changeset/25785-served-translations-without-metadata-forms.md b/.changeset/25785-served-translations-without-metadata-forms.md index d1dfcffbb5f..8fa52a32842 100644 --- a/.changeset/25785-served-translations-without-metadata-forms.md +++ b/.changeset/25785-served-translations-without-metadata-forms.md @@ -3,13 +3,19 @@ '@objectstack/runtime': patch --- -fix(i18n): the translation bundle a console downloads leaves out Studio's `metadataForms` +**BREAKING** fix(i18n)!: the translation bundle a console downloads leaves out Studio's `metadataForms` -Clause-②: no +Clause-②: yes (narrowing) Metadata-protocol: none `GET /i18n/translations/:locale` served the i18n service's whole locale tree, so every console open downloaded `metadataForms`, the translations of Studio's metadata-type authoring forms: 75 KB of the 221 KB raw zh-CN bundle on `examples/app-crm`. No browser reads that group from the bundle. Studio's forms already arrive translated, because `GET /meta` (and `/meta/types`) resolves `metadataForms.` on the server, from the same i18n service, before it serves each form. The route now serves every group except the ones the server resolves itself. The spec names them in `SERVER_RESOLVED_TRANSLATION_GROUPS`, which today holds only `metadataForms`. `GetTranslationsResponseSchema.translations` is now `ServedTranslationDataSchema`: the platform face without those groups, and closed, so a body that still carries one fails it. The runtime copies the tree and never edits the service's own one, which is the tree `/meta` translates from. The `ETag` still hashes the bytes served, so a warm reopen still gets `304`. -What to do: nothing, unless you read `metadataForms` from this route. Authoring is unchanged. `metadataForms` stays a group on every bundle face and in the i18n service. To show a metadata form in the reader's language, read it from `GET /meta/types`, which serves it already translated. +**Why this is breaking.** A response body that carries `metadataForms` was accepted by the published `GetTranslationsResponseSchema` and now fails it, the exported `GetTranslationsResponse` type loses that member, and the route stops serving a group it served. Nothing an author writes moves, which is why the kind is `none` and the level is `patch`. + +**What to do.** Nothing, unless you read `metadataForms` from this route. Read it from `GET /meta/types` instead, which serves each form already translated into the requested locale, rather than from the bundle this route returns. Authoring is unchanged: `metadataForms` stays a group on every bundle face (`TranslationData`, `PlatformTranslationData`), in `i18n:extract` and in the i18n service. + +Also on this route: `requestedLocale`, which the door has always served when it fell back to another locale (`zh` served as `zh-CN`), is now declared on `GetTranslationsResponseSchema`, optional. It was served undeclared, so no generated client could read it. + + diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index fcb72299250..b96dcc536e4 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1764,6 +1764,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **locale** | `string` | ✅ | Locale code | +| **requestedLocale** | `string` | optional | The locale the caller asked for, when the served locale is a fallback from it | | **translations** | `{ objects?: Record; picklists?: Record; apps?: Record; messages?: Record; … }` | ✅ | Translation data | ### Nested Shape: `GetTranslationsResponse.translations` diff --git a/packages/runtime/src/domains/i18n-translations-served-groups.test.ts b/packages/runtime/src/domains/i18n-translations-served-groups.test.ts index 877f54a29a5..55f453f8f88 100644 --- a/packages/runtime/src/domains/i18n-translations-served-groups.test.ts +++ b/packages/runtime/src/domains/i18n-translations-served-groups.test.ts @@ -113,10 +113,14 @@ describe('#25785 A — the served document leaves the server-resolved groups out expect(result.response.body.data.translations).not.toHaveProperty('metadataForms'); }); - it('the locale fallback exit (zh → zh-CN) leaves it out too', async () => { + it('the locale fallback exit (zh → zh-CN) leaves it out too, and its body is declared', async () => { const result = await read(makeService({ en: {}, 'zh-CN': zhTree() }), '/translations/zh'); expect(result.response.status).toBe(200); expect(result.response.body.data).toEqual({ locale: 'zh-CN', requestedLocale: 'zh', translations: SERVED_GROUPS }); + // `requestedLocale` is declared on the response face (#25785), so the + // fallback body is read by the schema rather than tolerated by it. + expect(GetTranslationsResponseSchema.safeParse(result.response.body.data).success).toBe(true); + expect(Object.keys(GetTranslationsResponseSchema.shape)).toContain('requestedLocale'); }); it('the body satisfies the closed GetTranslationsResponseSchema, which refuses the unserved group', async () => { diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 2b7d2eda8ed..601b7534407 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -832,6 +832,7 @@ "api/GetRunResponse:success", "api/GetTranslationsRequest:locale", "api/GetTranslationsResponse:locale", + "api/GetTranslationsResponse:requestedLocale", "api/GetTranslationsResponse:translations", "api/GetUiViewRequest:object", "api/GetUiViewRequest:type", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 1ec615cbc36..29ca838a5f8 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -3711,9 +3711,19 @@ export const GetTranslationsRequestSchema = lazySchema(() => z.object({ * translates Studio's authoring forms from it, and is not part of this answer. * `translations` is therefore {@link ServedTranslationDataSchema}, the platform * face without those groups, closed so a body that still carries one fails it. + * + * `requestedLocale` is the code the caller ASKED for, carried only when the + * door fell back to another one: `locale` is always the locale the body + * translates, and the pair is how a caller learns that `zh` was served as + * `zh-CN`. The runtime door has served it since that fallback existed while + * this face did not declare it (#25785) — declared ≠ enforced, Prime + * Directive #10: an undeclared field is one no generated client can read and + * no conformance suite can hold. Optional, because the exit that serves the + * locale asked for omits it. */ export const GetTranslationsResponseSchema = lazySchema(() => z.object({ locale: z.string().describe('Locale code'), + requestedLocale: z.string().optional().describe('The locale the caller asked for, when the served locale is a fallback from it'), translations: ServedTranslationDataSchema.describe('Translation data'), })); diff --git a/packages/spec/src/system/served-translation-data.test.ts b/packages/spec/src/system/served-translation-data.test.ts index 537f859cea6..5679a47fe3e 100644 --- a/packages/spec/src/system/served-translation-data.test.ts +++ b/packages/spec/src/system/served-translation-data.test.ts @@ -48,6 +48,18 @@ describe('#25785 — the served translation face', () => { expect(Object.keys(PlatformTranslationDataSchema.shape)).toContain(group); }); + it('declares `requestedLocale`, optional, beside the locale it served', () => { + const shape = GetTranslationsResponseSchema.shape; + expect(Object.keys(shape).sort()).toEqual(['locale', 'requestedLocale', 'translations']); + // The fallback exit's body: `zh` asked for, `zh-CN` served (runtime `domains/i18n.ts`). + const fallback = { locale: 'zh-CN', requestedLocale: 'zh', translations: { messages: { save: '保存' } } }; + expect(GetTranslationsResponseSchema.safeParse(fallback).success).toBe(true); + // Optional: the exit that serves the locale asked for omits it. + expect(GetTranslationsResponseSchema.safeParse({ locale: 'zh-CN', translations: {} }).success).toBe(true); + // Declared, so it is typed rather than merely tolerated: a non-string is refused. + expect(GetTranslationsResponseSchema.safeParse({ ...fallback, requestedLocale: 7 }).success).toBe(false); + }); + it('accepts a document carrying the served groups', () => { const served = { objects: { lead: { label: '潜在客户', fields: { company: { label: '公司' } } } }, diff --git a/ui/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts index f8c54eb2c09..5a572b947df 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts @@ -31,8 +31,16 @@ * (a hand-rolled walker would have had to re-derive `header.showTitle` and * would have got the section names wrong — they are slugged labels, not the * `label` text); - * - when the environment's own translation bundle reaches this surface, it - * drops in as a higher-precedence source with no call-site change. + * - when the environment's own `metadataForms` translations reach this + * surface, they drop in as a higher-precedence source with no call-site + * change. Their channel is `/api/v1/meta/types`, which serves each form + * already localized from the environment's bundle: the console's own + * translation bundle (`/api/v1/i18n/translations/:locale`) does not carry + * the group at all (objectstack#25785 — it was 75 KB of 221 KB on app-crm + * zh-CN, read by nothing in the browser). So the higher-precedence source + * for a form the SERVER renders is already in place; this overlay is for + * the forms this console renders from its own copy of the spec, which no + * server answer passes through. * * ## Scope: `en` + `zh`, matching this console's carve-out * diff --git a/ui/packages/i18n/src/utils/spec-translations.ts b/ui/packages/i18n/src/utils/spec-translations.ts index be3c8c318c7..0f3638a4788 100644 --- a/ui/packages/i18n/src/utils/spec-translations.ts +++ b/ui/packages/i18n/src/utils/spec-translations.ts @@ -60,18 +60,28 @@ export interface SpecTranslationData { } /** - * The top-level groups of the translation document the server serves at - * `/api/v1/i18n/translations/:locale` — the keys of the `translations` shape - * of `@objectstack/spec`'s `GetTranslationsResponseSchema`: every per-app + * The top-level groups this transform recognises in the translation document + * the server serves at `/api/v1/i18n/translations/:locale` — every per-app * `TranslationData` group plus the platform-only `settings`, because the * served document is the merge of the app's bundle and the platform's. * + * It is a SUPERSET of the keys of the `translations` shape of + * `@objectstack/spec`'s `GetTranslationsResponseSchema`, deliberately: that + * shape is the groups the server still SERVES, and a group can leave it while + * authored bundles and older servers keep carrying it. `metadataForms` is the + * first one to have left (objectstack#25785 — Studio's authoring forms arrive + * already translated from `/api/v1/meta/types`, so the console bundle stopped + * carrying their translations); it stays listed here, so a payload from a + * server that still serves it is namespaced like any other rather than landing + * untouched at the root. Nothing reads the group on this side. + * * Written out rather than imported: `@object-ui/i18n` deliberately does not * depend on `@objectstack/spec`. The console does, so the pin lives beside * its loader — `apps/console/src/loadLanguage.test.tsx` walks the spec's own * key list and requires a payload carrying any one group, alone, to take the - * transform branch. A group the spec adds and this list lacks turns that test - * red instead of dropping the group's bundles silently (objectui#10235). + * transform branch. A group the spec SERVES and this list lacks turns that + * test red instead of dropping the group's bundles silently (objectui#10235); + * a group listed here and no longer served is not red, by that direction. */ const SPEC_TRANSLATION_GROUPS = [ 'objects', From 4f5d2f0a417d404c7d44bc256585d25071d7c7e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:36:55 +0000 Subject: [PATCH 07/14] chore(sdui): re-record the i18n registry digest after the docblock fix Editing `ui/packages/i18n/src/utils/spec-translations.ts` moves that package's registry digest, so its record under `scripts/sdui-manifest.sources/` follows it. `sdui.manifest.json` is byte-identical: 106 components, sha256 1f6e1ccb925a. Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- scripts/sdui-manifest.sources/i18n.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/i18n.json b/scripts/sdui-manifest.sources/i18n.json index 873a389b2b8..755ed83fbad 100644 --- a/scripts/sdui-manifest.sources/i18n.json +++ b/scripts/sdui-manifest.sources/i18n.json @@ -1 +1 @@ -{"package":"ui/packages/i18n","sources":31,"sha256":"9cd75a1d5d100dec0273df9e1018574cc9188811a2af4963f04abc901ca95a9c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/i18n","sources":31,"sha256":"cdb9f0147ef4a924eb15cde1bea5071dde2d4cf91ecdad8d7b307d2629b6c23b","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From 4c82d13fcb6adcc057ffbc2e72ac350c3203f88a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:52:24 +0000 Subject: [PATCH 08/14] fix(i18n)!: declare the served-translations narrowing as Metadata-protocol narrowed, minor The authorable-surface shards this diff moves (the served face's keys and GetTranslationsResponse:requestedLocale) make it a metadata-protocol change, so Check Changeset needs a kind other than none and at least minor on a moved package. The changeset now declares Metadata-protocol: narrowed and ships @objectstack/spec and @objectstack/runtime at minor; the ADR-0087 reason is restated for the new kind. Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- ...25785-served-translations-without-metadata-forms.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/25785-served-translations-without-metadata-forms.md b/.changeset/25785-served-translations-without-metadata-forms.md index 8fa52a32842..5d57b133d65 100644 --- a/.changeset/25785-served-translations-without-metadata-forms.md +++ b/.changeset/25785-served-translations-without-metadata-forms.md @@ -1,21 +1,21 @@ --- -'@objectstack/spec': patch -'@objectstack/runtime': patch +'@objectstack/spec': minor +'@objectstack/runtime': minor --- **BREAKING** fix(i18n)!: the translation bundle a console downloads leaves out Studio's `metadataForms` Clause-②: yes (narrowing) -Metadata-protocol: none +Metadata-protocol: narrowed `GET /i18n/translations/:locale` served the i18n service's whole locale tree, so every console open downloaded `metadataForms`, the translations of Studio's metadata-type authoring forms: 75 KB of the 221 KB raw zh-CN bundle on `examples/app-crm`. No browser reads that group from the bundle. Studio's forms already arrive translated, because `GET /meta` (and `/meta/types`) resolves `metadataForms.` on the server, from the same i18n service, before it serves each form. The route now serves every group except the ones the server resolves itself. The spec names them in `SERVER_RESOLVED_TRANSLATION_GROUPS`, which today holds only `metadataForms`. `GetTranslationsResponseSchema.translations` is now `ServedTranslationDataSchema`: the platform face without those groups, and closed, so a body that still carries one fails it. The runtime copies the tree and never edits the service's own one, which is the tree `/meta` translates from. The `ETag` still hashes the bytes served, so a warm reopen still gets `304`. -**Why this is breaking.** A response body that carries `metadataForms` was accepted by the published `GetTranslationsResponseSchema` and now fails it, the exported `GetTranslationsResponse` type loses that member, and the route stops serving a group it served. Nothing an author writes moves, which is why the kind is `none` and the level is `patch`. +**Why this is breaking, and why `minor`.** A response body that carries `metadataForms` was accepted by the published `GetTranslationsResponseSchema` and now fails it, the exported `GetTranslationsResponse` type loses that member, and the route stops serving a group it served. The published protocol records the move: the authorable surface gains the served face's keys (`ServedTranslationData`) and `GetTranslationsResponse:requestedLocale`, and the response face a client parses against is narrower, so the kind is `narrowed` and both packages that carry it — the spec that declares the face and the runtime that serves it — ship `minor`. What an author writes into a translation bundle does not move. **What to do.** Nothing, unless you read `metadataForms` from this route. Read it from `GET /meta/types` instead, which serves each form already translated into the requested locale, rather than from the bundle this route returns. Authoring is unchanged: `metadataForms` stays a group on every bundle face (`TranslationData`, `PlatformTranslationData`), in `i18n:extract` and in the i18n service. Also on this route: `requestedLocale`, which the door has always served when it fell back to another locale (`zh` served as `zh-CN`), is now declared on `GetTranslationsResponseSchema`, optional. It was served undeclared, so no generated client could read it. - + From 48088279d379e5bdc0233d02c2d88126b817135b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:04:42 +0000 Subject: [PATCH 09/14] chore(sdui): re-record the i18n registry digest after merging main Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- scripts/sdui-manifest.sources/i18n.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/i18n.json b/scripts/sdui-manifest.sources/i18n.json index 6fe92078785..ba1575dda27 100644 --- a/scripts/sdui-manifest.sources/i18n.json +++ b/scripts/sdui-manifest.sources/i18n.json @@ -1 +1 @@ -{"package":"ui/packages/i18n","sources":31,"sha256":"ae5a36bc2327b985cb909eed35e099d1b2d953b7c4cb7dda463e4d8d3ef5cc86","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/i18n","sources":31,"sha256":"ac7dc6a696db477b090b0717d6c96b595446c4e20df82cfce67ec3bf1094bc08","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From ecf4c1e7001ac402cc82d7e9fd4ce861971d21fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:13:56 +0000 Subject: [PATCH 10/14] chore(sdui): re-record the i18n registry digest after merging main Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- scripts/sdui-manifest.sources/i18n.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/i18n.json b/scripts/sdui-manifest.sources/i18n.json index 9acdc472ef8..fdfc9eaefd3 100644 --- a/scripts/sdui-manifest.sources/i18n.json +++ b/scripts/sdui-manifest.sources/i18n.json @@ -1 +1 @@ -{"package":"ui/packages/i18n","sources":31,"sha256":"7e7907af7823796538ad5a70e56fb172dc533ec3988a2318c8aeddf7069af14a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/i18n","sources":31,"sha256":"b5ee7a4ffb944d6a0899ab95b9b94382d365380569941edee9219fd0c971af5b","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From bf688dc903d7c245e297513110149cfdeb3955f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:54:14 +0000 Subject: [PATCH 11/14] docs(spec): regenerate the reference index after merging main Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- content/docs/references/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 70638a97fe6..79a503311f2 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1545 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1546 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | -| [Data Protocol](/docs/references/data) | 30 | 180 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 30 | 181 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1545** | 14 protocol modules | +| **Total** | **199** | **1546** | 14 protocol modules | --- @@ -131,7 +131,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **30 pages, 180 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **30 pages, 181 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -157,7 +157,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`feed.zod.ts`](/docs/references/data/feed) | `FeedFilterMode`, `FeedItemType` | | [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldMaskingKeep`, `FieldMaskingRule`, `FieldType`, `InlineGridColumn`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | | [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileRefusedValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` | -| [`filter.zod.ts`](/docs/references/data/filter) | `ComparisonOperator`, `EqualityOperator`, `FieldOperators`, `FieldReference`, `FilterArray`, `FilterCondition`, `NormalizedFilter`, `QueryFilter`, `RangeOperator`, `SetOperator`, `SpecialOperator`, `StringOperator` | +| [`filter.zod.ts`](/docs/references/data/filter) | `ComparisonOperator`, `EqualityOperator`, `FieldOperators`, `FieldReference`, `FilterArray`, `FilterCondition`, `NormalizedFilter`, `NowAnchor`, `QueryFilter`, `RangeOperator`, `SetOperator`, `SpecialOperator`, `StringOperator` | | [`hook.zod.ts`](/docs/references/data/hook) | `Hook`, `HookContext`, `HookEvent` | | [`hook-body.zod.ts`](/docs/references/data/hook-body) | `ExpressionBody`, `HookBody`, `HookBodyCapability`, `ScriptBody` | | [`mapping.zod.ts`](/docs/references/data/mapping) | `ImportFieldMapping`, `Mapping`, `TransformType` | From 2cd96962bd9f13ff19d995da0651c927af53dd9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:04:15 +0000 Subject: [PATCH 12/14] chore(sdui): re-record the i18n registry digest after merging main Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- scripts/sdui-manifest.sources/i18n.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/i18n.json b/scripts/sdui-manifest.sources/i18n.json index 84836d6c007..7bb2e32d712 100644 --- a/scripts/sdui-manifest.sources/i18n.json +++ b/scripts/sdui-manifest.sources/i18n.json @@ -1 +1 @@ -{"package":"ui/packages/i18n","sources":31,"sha256":"4bcb36ff124e98b8405929277c2acd9afdeb73347b814df67cf26a80727996af","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/i18n","sources":31,"sha256":"43b6ee17a15b97ae844ac65582fd4633aa07c851f5578f7cdb0de66f5d4c6211","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From fec80c3eae6fd0a838b60bccceb940585a1d9331 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:24:07 +0000 Subject: [PATCH 13/14] chore(sdui): re-record the i18n registry digest after merging main Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- scripts/sdui-manifest.sources/i18n.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/i18n.json b/scripts/sdui-manifest.sources/i18n.json index 2d5791a08d1..852526bc3e8 100644 --- a/scripts/sdui-manifest.sources/i18n.json +++ b/scripts/sdui-manifest.sources/i18n.json @@ -1 +1 @@ -{"package":"ui/packages/i18n","sources":31,"sha256":"a5459185e0836b6ae5f87ff81d0e75d2be96455c5f18661c47c12c8f49d81871","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/i18n","sources":31,"sha256":"a104461c623321be317dd4bdb55e0f4f4bf1e5e661b5ed2d4ff6b0e1907a441a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From 392a610a7e66af7e878647124e343c5f46958733 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:40:57 +0000 Subject: [PATCH 14/14] chore(spec): regenerate the authorable surface and reference index after merging main Claude-Session: https://claude.ai/code/session_01Vy85beHdawtEtqNscYBy7V Co-authored-by: Claude --- content/docs/references/index.mdx | 9 +++++---- packages/spec/authorable-surface/api.json | 1 + packages/spec/authorable-surface/system.json | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 79a503311f2..c4be7fb7e5e 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1546 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1549 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | -| [Data Protocol](/docs/references/data) | 30 | 181 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 31 | 184 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1546** | 14 protocol modules | +| **Total** | **200** | **1549** | 14 protocol modules | --- @@ -131,7 +131,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **30 pages, 181 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **31 pages, 184 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -165,6 +165,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`picklist.zod.ts`](/docs/references/data/picklist) | `Picklist`, `PicklistExtension`, `PicklistServedField` | | [`query.zod.ts`](/docs/references/data/query) | `AggregationFunction`, `AggregationNode`, `DateGranularity`, `FieldNode`, `FullTextSearch`, `GroupByNode`, `Query`, `SortNode` | | [`seed.zod.ts`](/docs/references/data/seed) | `Seed`, `SeedColumn`, `SeedMode` | +| [`seed-file.zod.ts`](/docs/references/data/seed-file) | `SeedFileContent`, `SeedFilePath`, `SeedFileReference` | | [`seed-loader.zod.ts`](/docs/references/data/seed-loader) | `ObjectDependencyGraph`, `ObjectDependencyNode`, `ReferenceResolution`, `ReferenceResolutionError`, `SeedIdentity`, `SeedLoadResult`, `SeedLoaderConfig`, `SeedLoaderRequest`, `SeedLoaderResult` | | [`validation.zod.ts`](/docs/references/data/validation) | `ConditionalValidation`, `CrossFieldValidation`, `FormatValidation`, `JSONValidation`, `ScriptValidation`, `StateMachineValidation`, `ValidationRule` | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 601b7534407..cafe22ee4db 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -950,6 +950,7 @@ "api/ImportRowResult:code", "api/ImportRowResult:droppedFields", "api/ImportRowResult:error", + "api/ImportRowResult:errors", "api/ImportRowResult:field", "api/ImportRowResult:id", "api/ImportRowResult:ok", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 7fd32ae69f6..9e001fd0299 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -43,7 +43,7 @@ "system/AppInstallResult:seededRecords", "system/AppInstallResult:success", "system/AppInstallResult:version", - "system/AppManifest:dependencies", + "system/AppManifest:dependencies [RETIRED]", "system/AppManifest:description", "system/AppManifest:flows", "system/AppManifest:hasSeedData",