diff --git a/.changeset/i18n-catalog-loses-to-explicit-override.md b/.changeset/i18n-catalog-loses-to-explicit-override.md new file mode 100644 index 0000000000..8ef1801143 --- /dev/null +++ b/.changeset/i18n-catalog-loses-to-explicit-override.md @@ -0,0 +1,24 @@ +--- +"@objectstack/spec": patch +"@objectstack/metadata-protocol": patch +"@objectstack/rest": patch +--- + +fix(i18n): the object catalog no longer overwrites an explicitly-set `label` / `pluralLabel` / `description` + +`translateObject` resolved an object's three scalars as `catalog ?? document`. The +i18n catalog is keyed by object name and is the packaged translation of the +**packaged** declaration, so consulting it first discarded every value authored on +top of that declaration: a code-shipped `objectExtensions` scalar, and — the severe +half — a tenant's own Studio rename, which answered `200` and then appeared on +neither `GET /meta/object` nor `GET /meta/object/:name`, i.e. neither read a +writable form derives from. + +The catalog now applies only while the document's scalar still equals the packaged +base value; a scalar that differs was authored by somebody, and the catalog yields +to it. Comparison-based, per scalar, with no provenance flag carried through the +fold: `@objectstack/metadata-protocol` exposes the packaged owner declaration +(`getPackagedObjectBase`) and `@objectstack/rest` hands it to the translator at the +three sites that localize an object document. A host whose protocol does not +answer keeps the previous behaviour exactly, so nothing loses a translation it has +today. `?layers=true` stays untranslated and diagnostic, unchanged. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 073a6492b1..cc647ab616 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4445,6 +4445,50 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#8284] The PACKAGED (code-layer) OWNER declaration of an object — + * the base layer, BEFORE `objectExtensions` are folded onto it + * (ADR-0029 D9.2) and before any tenant `sys_metadata` overlay. + * + * Read-only, synchronous, and the ONE fact the localization boundary + * cannot derive for itself. `translateObject` + * (`@objectstack/spec/system`) resolves an object's `label` / + * `pluralLabel` / `description` against the i18n catalog, which is keyed + * by object name and is the packaged translation of exactly this + * declaration. Consulting it unconditionally overwrote every scalar that + * had been authored ON TOP of the declaration — a code-shipped extension + * scalar, and the tenant's own Studio rename, which answered `200` and + * then appeared on neither `/meta/object` read (#8284, maintainer ruling + * 2026-08-13: the catalog loses to an explicit override, decided by + * comparison against this value). + * + * ⛔ NOT {@link lookupArtifactItem}, whose object branch answers + * `resolveOwnerLayer` — owner **plus its extenders** (see + * `SchemaRegistry.getArtifactItem`). That body already carries the + * extension's scalar, so comparing against it would call the extension's + * label "unchanged" and hand the catalog back the very case #8037 filed. + * The discriminator is `getPackagedObjectOwner`, which is the same + * "does a code package ship this?" test (`isCodeArtifactBody`) applied to + * the OWNER contributor alone. + * + * Returns `undefined` — meaning "no packaged baseline, do not infer + * anything" — for a runtime/tenant-authored object (no code owner), an + * unknown name, and a partial registry double. The caller's contract is + * that absence restores the pre-#8284 behaviour rather than guessing. + */ + getPackagedObjectBase(name: string): unknown { + if (typeof name !== 'string' || name === '') return undefined; + const registry = (this.engine as any)?.registry; + if (!registry || typeof registry.getPackagedObjectOwner !== 'function') return undefined; + try { + return registry.getPackagedObjectOwner(name)?.definition; + } catch { + // Same rule as the fold above: a read over in-memory contributors + // must never turn a served schema into a 5xx. + return undefined; + } + } + /** * [#8268, generalising #8038] The REGISTRY-SIDE half of * {@link governServedItem}'s presence convergence: replay the registry's diff --git a/packages/objectql/src/protocol-packaged-object-base.test.ts b/packages/objectql/src/protocol-packaged-object-base.test.ts new file mode 100644 index 0000000000..d13b581ac0 --- /dev/null +++ b/packages/objectql/src/protocol-packaged-object-base.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8284 — `ObjectStackProtocolImplementation.getPackagedObjectBase`, against a + * REAL `SchemaRegistry`. + * + * The localization boundary decides whether an object's `label` / + * `pluralLabel` / `description` may be replaced by the i18n catalog by + * comparing the served document against the PACKAGED declaration (maintainer + * ruling, 2026-08-13: the catalog loses to an explicit override, decided by + * comparison, with no provenance flag carried through the fold). Everything + * about that rule is decided by which body this accessor returns, and that is + * a registry question — hence a test here rather than against a double. + * + * ⛔ THE TRAP THIS FILE EXISTS FOR. The obvious accessor is the one the + * protocol already uses for lock/provenance — `getArtifactItem`, whose object + * branch answers `resolveOwnerLayer`, i.e. the owner **with its extenders + * folded on**. That body already carries the extension's label, so a + * comparison against it would report the extension's scalar as "unchanged" and + * hand the catalog straight back the case #8037 was filed about. The two are + * pinned side by side below so the difference cannot be re-discovered by + * accident. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from './registry.js'; +import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js'; +import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; + +const PKG = 'app.showcase'; +const OBJ = 'showcase_account'; + +/** What the package's own declaration says — the catalog's subject. */ +const PACKAGED_LABEL = 'Account'; +/** What the package's `objectExtensions` entry says. */ +const EXTENSION_LABEL = 'Account (Success Overlay)'; + +const rowKey = (w: Record) => + [w.type, w.name, w.organization_id ?? '', w.package_id ?? '', w.state ?? 'active'].join('|'); + +const matchesWhere = (row: Record, where: Record) => + Object.entries(where ?? {}).every(([k, v]) => { + if (v === null) return row[k] === null || row[k] === undefined; + return row[k] === v; + }); + +/** + * One kernel process: a real registry + the real protocol over an in-memory + * `sys_metadata`. Same shape as `protocol-object-overlay-layer.test.ts`, which + * is the sibling file for the layer model this accessor reads. + */ +function makeSession() { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + const rows = new Map(); + const historyRows: any[] = []; + let nextId = 0; + + const findRow = (w: Record) => { + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const engine: any = { + registry, + async findOne(table: string, o: { where: Record }) { + if (table === 'sys_metadata_history') return historyRows.find((h) => matchesWhere(h, o.where)) ?? null; + if (table !== 'sys_metadata') return null; + return findRow(o.where)?.row ?? null; + }, + async find(table: string, o: { where: Record }) { + if (table === 'sys_metadata_history') return historyRows.filter((h) => matchesWhere(h, o.where)); + if (table !== 'sys_metadata') return []; + return Array.from(rows.values()).filter((r) => matchesWhere(r, o.where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_history') { + const h = { id: `h_${++nextId}`, ...(data as any) }; + historyRows.push(h); + return { id: h.id }; + } + if (table !== 'sys_metadata') return { id: `rec_${++nextId}` }; + const row = { id: `r_${++nextId}`, ...(data as any) }; + rows.set(rowKey(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, o: { where: Record }) { + assertEngineUpdateDispatch(data, o); + if (table !== 'sys_metadata') return { id: null }; + const found = findRow(o.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(rowKey(merged), merged); + return { id: merged.id }; + }, + async delete(table: string, o?: Record) { + assertEngineDeleteDispatch(o); + if (table !== 'sys_metadata') return { deleted: 0 }; + const found = findRow(((o as any)?.where ?? {}) as Record); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async syncObjectSchema() { /* no physical storage in this double */ }, + }; + + const protocol = new ObjectStackProtocolImplementation(engine, undefined, 'env_test'); + return { protocol, engine, registry, rows }; +} + +/** The packaged object, as a code package registers it. */ +function registerPackaged(registry: SchemaRegistry) { + registry.installPackage({ id: PKG, name: 'Showcase', version: '1.0.0' } as any); + registry.registerObject( + { + name: OBJ, + label: PACKAGED_LABEL, + pluralLabel: 'Accounts', + description: 'A company the org delivers projects for.', + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, + } as any, + PKG, + ); +} + +/** The package's own `objectExtensions` entry — an `extend` contributor. */ +function registerExtension(registry: SchemaRegistry) { + registry.registerObject( + { + name: OBJ, + label: EXTENSION_LABEL, + fields: { loyalty_tier: { name: 'loyalty_tier', type: 'text', label: 'Loyalty Tier' } }, + } as any, + PKG, + undefined, + 'extend', + 210, + ); +} + +const baseOf = (protocol: any, name = OBJ): any => protocol.getPackagedObjectBase(name); + +describe('#8284 getPackagedObjectBase — the packaged declaration, pre-fold', () => { + it('answers the OWNER declaration, not the extender-folded artifact body', () => { + const s = makeSession(); + registerPackaged(s.registry); + registerExtension(s.registry); + + expect(baseOf(s.protocol)?.label).toBe(PACKAGED_LABEL); + + // The trap, pinned from the other side: the accessor the protocol uses + // for lock/provenance answers the FOLDED body for the same name. + expect((s.registry.getArtifactItem('object', OBJ) as any)?.label).toBe(EXTENSION_LABEL); + // …which is also what the resolved schema — what a read serves — says. + expect((s.registry.getObject(OBJ) as any)?.label).toBe(EXTENSION_LABEL); + }); + + it('carries all three scalars the ruling covers', () => { + const s = makeSession(); + registerPackaged(s.registry); + const base = baseOf(s.protocol); + expect(base?.label).toBe(PACKAGED_LABEL); + expect(base?.pluralLabel).toBe('Accounts'); + expect(base?.description).toBe('A company the org delivers projects for.'); + }); + + it('survives a tenant overlay — the packaged layer is still underneath', async () => { + // ADR-0029 D9.7/D9.8: an overlay is its own layer, so the comparison + // baseline does not move when a tenant customises the object. If it + // did, a renamed object would compare equal to its own rename and the + // catalog would win again the moment the row landed. + const s = makeSession(); + registerPackaged(s.registry); + registerExtension(s.registry); + s.registry.registerObject( + { name: OBJ, label: 'Customer', _packageId: 'sys_metadata', _provenance: 'org' } as any, + 'sys_metadata', + undefined, + 'overlay', + ); + + expect(baseOf(s.protocol)?.label).toBe(PACKAGED_LABEL); + }); + + it('is undefined for an unknown name', () => { + const s = makeSession(); + registerPackaged(s.registry); + expect(baseOf(s.protocol, 'no_such_object')).toBeUndefined(); + expect(baseOf(s.protocol, '')).toBeUndefined(); + }); + + it('is undefined for a runtime/tenant-authored object with no code owner', () => { + // "No packaged baseline" is a real answer, and the localization + // boundary reads it as "infer nothing" — such an object keeps the + // pre-#8284 `catalog ?? document` behaviour rather than losing its + // translations to a guess. + const s = makeSession(); + s.registry.registerObject( + { name: 'tenant_thing', label: 'Tenant Thing', _packageId: 'sys_metadata', _provenance: 'org' } as any, + 'sys_metadata', + ); + expect(baseOf(s.protocol, 'tenant_thing')).toBeUndefined(); + }); + + it('is undefined when the host registry cannot answer', () => { + // Partial registry doubles predate this method; a host that cannot + // answer must degrade, never throw — the same rule the fold seam next + // to it follows. + const protocol: any = new ObjectStackProtocolImplementation({ registry: {} } as any, undefined, 'env_test'); + expect(protocol.getPackagedObjectBase(OBJ)).toBeUndefined(); + const noRegistry: any = new ObjectStackProtocolImplementation({} as any, undefined, 'env_test'); + expect(noRegistry.getPackagedObjectBase(OBJ)).toBeUndefined(); + }); +}); diff --git a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts index 33d1953ba3..d9a4123b34 100644 --- a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts @@ -36,13 +36,42 @@ // rename reaches `layers.overlay` and nothing else: both reads every writable // form derives from keep serving the packaged catalog string. That is the // scenario #8027/#8045 were entirely about ("an admin renaming the object's -// label in Studio"), and it is why this file escalates rather than pinning a +// label in Studio"), and it is why this file escalated rather than pinning a // preference — see the report on #8037. // -// The `it.fails` cases below are the invariants that SHOULD hold, quarantined in -// the repo's existing xfail idiom (see `field-zoo-roundtrip.dogfood.test.ts`). -// They pass while the defect stands and turn RED the moment it is fixed, which -// is what makes them a handover rather than a pin of current behaviour. +// ══════════════════════════════════════════════════════════════════════════ +// [#8284] WHAT THE RULING CHANGED, AND WHERE THIS FILE NOW STANDS +// ══════════════════════════════════════════════════════════════════════════ +// +// Maintainer ruling, 2026-08-13: the catalog LOSES to an explicit override, +// decided by COMPARISON — the catalog value applies only while the document's +// scalar still equals the packaged base value; a scalar that differs was +// explicitly set and the catalog yields. No provenance flag is carried through +// the fold, all three scalars, one mechanism. `?layers=true` stays untranslated +// and diagnostic. Implemented in `translateObject`, with the packaged base +// handed in by the REST boundary from +// `ObjectStackProtocolImplementation.getPackagedObjectBase`. +// +// So the first `it.fails` above is now a plain green case: the three reads of +// one object serve ONE label, and it is the folded one. +// +// ⛔ THE SECOND ONE IS NOT, AND THE REASON IS A SECOND DEFECT ONE LAYER DOWN. +// A tenant's rename still does not reach those reads — but no longer because of +// the catalog. `mergeObjectDefinitions` applies an extender's scalars LAST onto +// whatever base it is given, and ADR-0029 D9.2 makes the tenant's overlay that +// base (`overlay ?? own`, extenders folded on). So the showcase extension's +// `label: 'Account (Success Overlay)'` overwrites the tenant's 'Customer' +// inside the fold, and the value is simply not in the document any read is +// serving. The card measured this without naming it — its own table records +// `layers.effective = "Account (Success Overlay)"` after the rename, i.e. the +// extension had already beaten the overlay before i18n ever ran. +// +// Whether a package extension's label should outrank a tenant's Studio rename +// is a fold-precedence decision the 2026-08-13 ruling did not make, and it is +// NOT arm B (nothing here proposes dropping scalars from the fold). It is filed +// as a sub-issue of #8284; the `it.fails` case below stays exactly as it was +// written, so it flips to green the day that ruling lands — and the case after +// it pins what IS true today, so the state is not merely absent from the file. import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -122,7 +151,15 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc expect(res.status).toBe(200); const body: any = await res.json(); expect(labelOf(body?.code)).toBe(EXTENSION_LABEL); - expect(await listedLabel()).toBe(CATALOG_LABEL); + // [#8284] The catalog half is read from the SHIPPED BUNDLE, not from a + // served label any more. It used to be asserted as "the list read + // answers `Account`" — which was only true because the catalog was + // overwriting the fold, i.e. that assertion WAS the defect, and it + // inverts with the fix. The declaration itself is what this premise is + // about, and the stack carries it (`translations:` in + // `objectstack.config.ts`). + const catalogEn = (showcaseStack as any)?.translations?.[0]?.en?.objects?.showcase_account; + expect(catalogEn?.label).toBe(CATALOG_LABEL); expect(EXTENSION_LABEL).not.toBe(CATALOG_LABEL); }); @@ -144,16 +181,22 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc expect(labelOf(body?.item)).toBe(await listedLabel()); }); - it.fails('SHOULD: all three reads of one object serve one label', async () => { + it('all three reads of one object serve one label', async () => { const single = await stack.apiAs(token, 'GET', '/meta/object/showcase_account'); const singleBody: any = await single.json(); const layered = await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true'); const layeredBody: any = await layered.json(); - // `effective` is documented as "what `getMetaItem` would return". It is - // not, and this is the sentence that stops being true. + // [#8284] `effective` is documented as "what `getMetaItem` would + // return", and as of the 2026-08-13 ruling that sentence is true again: + // the catalog no longer overwrites the extension's scalar on the way + // out, so the diagnostic and the two translated reads agree. expect(labelOf(layeredBody?.effective)).toBe(labelOf(singleBody?.item)); expect(labelOf(layeredBody?.effective)).toBe(await listedLabel()); + // …and the value they agree on is the FOLDED one, not the catalog's. + // Asserting only the agreement would stay green if a later change made + // all three serve `Account` again. + expect(labelOf(singleBody?.item)).toBe(EXTENSION_LABEL); }); it.fails('SHOULD: a tenant\'s own rename reaches the reads its forms derive from', async () => { @@ -175,4 +218,33 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc expect(labelOf(after?.item)).toBe('Customer'); expect(await listedLabel()).toBe('Customer'); }); + + it('[#8284] after the rename the three reads still AGREE — on the extension, not the catalog', async () => { + // What the ruling actually bought in the renamed state, pinned so the + // `it.fails` above is not the file's only word about it. The tenant's + // value is absent from every read because `mergeObjectDefinitions` + // applies the extender's scalar LAST onto the overlay base + // (ADR-0029 D9.2) — the second defect named in this file's header, and + // the one the `it.fails` is now waiting on. What #8284 removed is the + // DISAGREEMENT: no read serves the packaged catalog string any more. + // + // Performs its own PUT rather than leaning on the case above: an + // `it.fails` stops at its first failing assertion, so depending on its + // side effects would make this case's meaning depend on where that + // happens to be. + const before: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json(); + const put = await stack.apiAs(token, 'PUT', '/meta/object/showcase_account', { + ...(before?.item ?? {}), label: 'Customer', + }); + expect(put.status).toBeLessThan(400); + + const layered: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account?layers=true')).json(); + expect(labelOf(layered?.overlay)).toBe('Customer'); + + const after: any = await (await stack.apiAs(token, 'GET', '/meta/object/showcase_account')).json(); + expect(labelOf(after?.item)).toBe(labelOf(layered?.effective)); + expect(await listedLabel()).toBe(labelOf(layered?.effective)); + // ⛔ And NOT the catalog string, which is what all three used to serve. + expect(labelOf(after?.item)).not.toBe(CATALOG_LABEL); + }); }); diff --git a/packages/rest/src/meta-object-i18n-explicit-override.test.ts b/packages/rest/src/meta-object-i18n-explicit-override.test.ts new file mode 100644 index 0000000000..3d12873987 --- /dev/null +++ b/packages/rest/src/meta-object-i18n-explicit-override.test.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8284 — the i18n catalog loses to an explicitly-set object scalar, and the + * value that decides it reaches the translator. + * + * The RULE lives in `@objectstack/spec/system` (`translateObject`, unit-tested + * in `i18n-resolver.test.ts` across the whole truth table). What can only be + * tested here is the PLUMBING: `translateObject` compares the served document + * against the packaged base declaration, which is a fact no spec function can + * reach — it comes from the protocol (`getPackagedObjectBase`, over + * `SchemaRegistry.getPackagedObjectOwner`) and is handed in by this boundary. + * A perfect rule that never receives a base is exactly the defect it fixes, so + * these cases assert the response body, not a call. + * + * The three seams that translate an object document are all covered: + * `GET /meta/:type` (list), `GET /meta/:type/:name` (single), and the + * feature-detection contract for a protocol that predates the accessor. + * + * Maintainer ruling, 2026-08-13: a tenant's explicit scalar (a Studio rename + * that answered `200`) and a code-shipped `objectExtensions` scalar are both + * authored data and must win over the packaged catalog; decided by comparison + * against the packaged base, with no provenance flag carried through the fold. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server.js'; + +// --------------------------------------------------------------------------- +// Fixtures — one object, one catalog, three documents +// --------------------------------------------------------------------------- + +/** + * The packaged declaration, as the owner contributor holds it: pre-fold, + * pre-overlay. This is what `getPackagedObjectBase` answers with. + */ +const PACKAGED = { + name: 'showcase_account', + label: 'Account', + pluralLabel: 'Accounts', + description: 'A company the org delivers projects for.', + fields: { industry: { name: 'industry', type: 'select', label: 'Industry' } }, +}; + +/** The served document when a code extension has folded its own scalars on. */ +const EXTENDED = { ...PACKAGED, label: 'Account (Success Overlay)' }; + +/** The served document after a tenant's Studio rename. */ +const RENAMED = { ...PACKAGED, label: 'Customer' }; + +/** + * The packaged catalog. `en` repeats the packaged strings (what `i18n:extract` + * writes); `zh-CN` translates them. Both locales are exercised: a rule that + * only withheld the catalog in translation would still serve the packaged + * English back over a rename to an `en` session. + */ +const BUNDLE: Record = { + en: { + objects: { + showcase_account: { label: 'Account', pluralLabel: 'Accounts' }, + }, + }, + 'zh-CN': { + objects: { + showcase_account: { label: '客户', pluralLabel: '客户', fields: { industry: { label: '行业' } } }, + showcase_contact: { label: '联系人' }, + }, + }, +}; + +const i18nService = { + getLocales: () => ['en', 'zh-CN'], + getTranslations: (locale: string) => BUNDLE[locale], + getDefaultLocale: () => 'en', +}; + +// --------------------------------------------------------------------------- +// Doubles +// --------------------------------------------------------------------------- + +function mockServer() { + return { + 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), + }; +} + +function mockRes() { + return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() }; +} + +/** + * @param served the object document the protocol serves (post-fold, + * post-overlay — what a real read hands the boundary). + * @param packagedBase what `getPackagedObjectBase` answers, or `null` to build + * a protocol that does NOT implement the accessor at all (the older-host + * control). + */ +function protocolFor(served: any, packagedBase: any) { + const base: Record = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', + routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: any) => (type === 'object' || type === 'objects' ? [served] : [])), + getMetaItem: vi.fn(async ({ type, name }: any) => ({ + type: type === 'objects' ? 'object' : type, + name, + item: served, + lock: 'none', + editable: true, + })), + getMetaItemCached: undefined as any, + findData: vi.fn().mockResolvedValue([]), + }; + if (packagedBase !== null) { + base.getPackagedObjectBase = vi.fn((name: string) => + name === packagedBase?.name ? packagedBase : undefined, + ); + } + return base; +} + +function makeRest(protocol: any) { + const rest = new RestServer( + mockServer() as any, protocol as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + // i18nServiceProvider — the 14th constructor argument. + async () => i18nService as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + return rest; +} + +function routeFor(rest: RestServer, path: string) { + const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path); + if (!route) throw new Error(`route not registered: GET ${path}`); + return route; +} + +/** + * The body of the last `res.json(...)`. Indexed rather than `.at(-1)`: this + * package's `lib` target predates ES2022 (see `meta-plural-i18n.test.ts`). + */ +function lastBody(res: ReturnType): any { + const calls = res.json.mock.calls; + return calls.length ? calls[calls.length - 1][0] : undefined; +} + +async function listLabel(rest: RestServer, locale = 'zh-CN'): Promise { + const res = mockRes(); + await routeFor(rest, '/api/v1/meta/:type').handler( + { method: 'GET', params: { type: 'object' }, query: {}, body: {}, headers: { 'accept-language': locale } }, + res, + ); + const body = lastBody(res); + const items = Array.isArray(body) ? body : body?.items ?? []; + return items[0]?.label; +} + +async function itemLabel(rest: RestServer, locale = 'zh-CN'): Promise { + const res = mockRes(); + await routeFor(rest, '/api/v1/meta/:type/:name').handler( + { + method: 'GET', + params: { type: 'object', name: 'showcase_account' }, + query: {}, + body: {}, + headers: { 'accept-language': locale }, + }, + res, + ); + return lastBody(res)?.item?.label; +} + +// --------------------------------------------------------------------------- +// §1 — the packaged default is still translated +// --------------------------------------------------------------------------- + +describe('#8284 §1 — an untouched object keeps its catalog translation', () => { + // The majority path. Stated first because it is what a comparison-based + // rule risks: withholding the catalog from documents that never diverged + // would be a far larger regression than the defect being fixed. + const rest = () => makeRest(protocolFor(PACKAGED, PACKAGED)); + + it('list read serves the catalog label', async () => { + expect(await listLabel(rest())).toBe('客户'); + }); + + it('by-name read serves the catalog label', async () => { + expect(await itemLabel(rest())).toBe('客户'); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — an explicitly-set scalar beats the catalog, on BOTH reads +// --------------------------------------------------------------------------- + +describe('#8284 §2 — an explicit override reaches both /meta/object reads', () => { + it("a code extension's label survives the list read", async () => { + expect(await listLabel(makeRest(protocolFor(EXTENDED, PACKAGED)))).toBe('Account (Success Overlay)'); + }); + + it("a code extension's label survives the by-name read", async () => { + expect(await itemLabel(makeRest(protocolFor(EXTENDED, PACKAGED)))).toBe('Account (Success Overlay)'); + }); + + it("a tenant's rename survives the list read", async () => { + expect(await listLabel(makeRest(protocolFor(RENAMED, PACKAGED)))).toBe('Customer'); + }); + + it("a tenant's rename survives the by-name read", async () => { + // The severe half of the card: this is the read every writable form + // derives from, and it used to answer the packaged catalog string + // after a `PUT` that returned 200. + expect(await itemLabel(makeRest(protocolFor(RENAMED, PACKAGED)))).toBe('Customer'); + }); + + it('and in the SOURCE locale, where the catalog repeats the packaged string', async () => { + expect(await itemLabel(makeRest(protocolFor(RENAMED, PACKAGED)), 'en')).toBe('Customer'); + expect(await listLabel(makeRest(protocolFor(RENAMED, PACKAGED)), 'en')).toBe('Customer'); + }); + + it('leaves everything else on the document translated', async () => { + // Withholding the catalog is per-scalar, not per-document: the field + // labels and the untouched `pluralLabel` still localize. + const res = mockRes(); + await routeFor(makeRest(protocolFor(RENAMED, PACKAGED)), '/api/v1/meta/:type/:name').handler( + { + method: 'GET', + params: { type: 'object', name: 'showcase_account' }, + query: {}, body: {}, headers: { 'accept-language': 'zh-CN' }, + }, + res, + ); + const item = lastBody(res)?.item; + expect(item.label).toBe('Customer'); + expect(item.pluralLabel).toBe('客户'); + expect(item.fields.industry.label).toBe('行业'); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — the feature-detection contract +// --------------------------------------------------------------------------- + +describe('#8284 §3 — a protocol without the accessor keeps its translations', () => { + // `RestProtocol` is the ADR-0076 D9 wire slice and is deliberately NOT + // widened for this (server-only extensions are runtime-cast). So the + // boundary must degrade to the pre-#8284 answer rather than guessing — + // "no baseline known" is not "explicitly set". + it('falls back to catalog-wins when getPackagedObjectBase is absent', async () => { + const rest = makeRest(protocolFor(RENAMED, null)); + expect(await itemLabel(rest)).toBe('客户'); + expect(await listLabel(rest)).toBe('客户'); + }); + + it('and when the accessor answers undefined (runtime-authored object)', async () => { + // A tenant-authored object has no packaged owner at all. Same rule: + // nothing to compare, so nothing is withheld. + const rest = makeRest(protocolFor({ ...RENAMED, name: 'showcase_account' }, { name: 'other_object' })); + expect(await itemLabel(rest)).toBe('客户'); + }); + + it('asks only for objects — no lookup for a non-object type', async () => { + const protocol = protocolFor(PACKAGED, PACKAGED); + const rest = makeRest(protocol); + const res = mockRes(); + await routeFor(rest, '/api/v1/meta/:type').handler( + { method: 'GET', params: { type: 'page' }, query: {}, body: {}, headers: { 'accept-language': 'zh-CN' } }, + res, + ); + expect(protocol.getPackagedObjectBase).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index ca85bd14c5..af6d3af8ed 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3393,6 +3393,41 @@ export class RestServer { return Object.keys(bundle).length ? bundle : undefined; } + /** + * [#8284] The packaged (code-layer) base declaration of an OBJECT, for the + * localization boundary — `translateObject`'s + * `TranslateDocumentOptions.packagedBase`. + * + * The i18n catalog is keyed by object name and is the packaged translation + * of the packaged declaration, so it must yield to any scalar that has + * been authored on top of that declaration: a code-shipped + * `objectExtensions` label, and the tenant's own Studio rename — which + * answered `200` and then appeared on neither of the two reads a writable + * form derives from (maintainer ruling 2026-08-13; the comparison itself + * lives in `@objectstack/spec/system`, which is where the rule belongs — + * this method only hands it the value it cannot see). + * + * `undefined` on every uncertainty, and that is contractual rather than + * defensive: the spec-side rule reads absence as "no baseline known" and + * falls back to the pre-#8284 `catalog ?? document`, so a host whose + * protocol predates this method (or a partial protocol double) keeps + * exactly the behaviour it has today instead of losing its translations. + * + * Feature-detected because `RestProtocol` is the ADR-0076 D9 wire slice + * and server-only extensions are detected via runtime casts rather than + * widening it — the same shape `getMetaItemLayered` is consumed with. + */ + private packagedObjectBase(p: any, type: string, name: unknown): unknown { + if (type !== 'object') return undefined; + if (typeof name !== 'string' || name === '') return undefined; + if (!p || typeof p.getPackagedObjectBase !== 'function') return undefined; + try { + return p.getPackagedObjectBase(name); + } catch { + return undefined; + } + } + /** * Parse the highest-priority locale from an `Accept-Language` header. * Falls back to a `?locale=` query parameter, then to the i18n service's @@ -3493,7 +3528,17 @@ export class RestServer { const locale = this.extractLocale(req, i18n); if (!locale) return item; const { translateMetadataDocument } = await import('@objectstack/spec/system'); - return translateMetadataDocument(metaType, item, bundle, { locale }); + // [#8284] The packaged baseline the catalog is a translation OF — see + // `packagedObjectBase`. Resolved through the request's own protocol so + // a multi-tenant read asks the kernel that actually serves it. + const packagedBase = metaType === 'object' + ? this.packagedObjectBase( + await this.resolveProtocol(environmentId, req).catch(() => undefined), + metaType, + (item as any)?.name, + ) + : undefined; + return translateMetadataDocument(metaType, item, bundle, { locale, packagedBase }); } /** @@ -3686,10 +3731,18 @@ export class RestServer { const locale = this.extractLocale(req, i18n); if (!locale) return items; const { translateMetadataDocument } = await import('@objectstack/spec/system'); + // [#8284] One protocol resolution for the whole page; the lookup + // itself is a synchronous in-memory registry read per element. + const p = metaType === 'object' + ? await this.resolveProtocol(environmentId, req).catch(() => undefined) + : undefined; // `getMetaItems` elements are metadata documents (the list envelope is // the OUTER `{ type, items }`), so every element translates directly — // #5563 removed the per-element shape sniff that stood here. - const translated = arr.map((item) => translateMetadataDocument(metaType, item, bundle, { locale })); + const translated = arr.map((item) => translateMetadataDocument(metaType, item, bundle, { + locale, + packagedBase: this.packagedObjectBase(p, metaType, item?.name), + })); return Array.isArray(items) ? translated : { ...items, items: translated }; } @@ -8582,7 +8635,16 @@ export class RestServer { const locale = this.extractLocale(req, i18n); if (bundle && locale) { const { translateMetadataDocument } = await import('@objectstack/spec/system'); - objectSchema = translateMetadataDocument('object', objectSchema, bundle, { locale }); + // [#8284] Same rule as the two `/meta/object` + // reads: the catalog yields to a scalar the + // package's own extension or the tenant + // authored. The public form must not be the + // one surface still serving the packaged + // string back at a tenant who renamed it. + objectSchema = translateMetadataDocument('object', objectSchema, bundle, { + locale, + packagedBase: this.packagedObjectBase(p, 'object', objectSchema?.name), + }); } } catch (e: any) { logError('[REST] Public form schema translation failed:', e); diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 79324d2b8b..9738536db0 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -717,6 +717,7 @@ "TrainingPlanSchema (const)", "TrainingRecord (type)", "TrainingRecordSchema (const)", + "TranslateDocumentOptions (interface)", "TranslationBundle (type)", "TranslationBundleSchema (const)", "TranslationConfig (type)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 80c314be33..0b3faa2507 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -717,6 +717,7 @@ "TrainingPlanSchema": "src/system/training.zod.ts#TrainingPlanSchema (const)", "TrainingRecord": "src/system/training.zod.ts#TrainingRecord (type)", "TrainingRecordSchema": "src/system/training.zod.ts#TrainingRecordSchema (const)", + "TranslateDocumentOptions": "src/system/i18n-resolver.ts#TranslateDocumentOptions (interface)", "TranslationBundle": "src/system/translation.zod.ts#TranslationBundle (type)", "TranslationBundleSchema": "src/system/translation.zod.ts#TranslationBundleSchema (const)", "TranslationConfig": "src/system/translation.zod.ts#TranslationConfig (type)", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 5acd0ee4ef..f06feb18cc 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1806,3 +1806,186 @@ describe('normalizeSupportedLocales (#7679)', () => { ]); }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// translateObject — the catalog loses to an explicit override (#8284) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * #8284 — `label` / `pluralLabel` / `description` used to resolve as a flat + * `catalog ?? document`, so the packaged catalog entry overwrote whatever had + * been authored on top of the packaged declaration: a code-shipped + * `objectExtensions` scalar, and — the severe half — the tenant's own Studio + * rename, which answered `200` and then reached neither `GET /meta/object` nor + * `GET /meta/object/:name`. + * + * Maintainer ruling, 2026-08-13: the catalog loses to an explicit override, + * decided by COMPARISON against the packaged base — no provenance flag is + * carried through the fold. These cases are the rule's whole truth table; the + * end-to-end shape is pinned in + * `packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts`. + */ +describe('translateObject — catalog vs explicit override (#8284)', () => { + /** The packaged declaration, as the owner contributor holds it. */ + const PACKAGED = { + name: 'showcase_account', + label: 'Account', + pluralLabel: 'Accounts', + description: 'A company the org delivers projects for.', + }; + + /** + * A catalog carrying all three scalars in BOTH locales. `en` repeats the + * packaged strings (what `i18n:extract` writes); `zh-CN` is the translation. + * Both are needed: a rule that only looked at the requested locale would + * pass every zh case here and still be wrong for an `en` session. + */ + const BUNDLE: TranslationBundle = { + en: { + objects: { + showcase_account: { + label: 'Account', + pluralLabel: 'Accounts', + description: 'A company the org delivers projects for.', + }, + }, + } as any, + 'zh-CN': { + objects: { + showcase_account: { + label: '客户', + pluralLabel: '客户', + description: '本组织为其交付项目的公司。', + }, + }, + } as any, + }; + + const zh = (doc: any, packagedBase?: unknown) => + translateObject(doc, BUNDLE, { locale: 'zh-CN', packagedBase }); + + it('an untouched document still gets the catalog — all three scalars', () => { + // The majority path, and the one a comparison-based rule must not break: + // the document carries exactly what the package shipped, so the catalog IS + // its translation. + const out = zh({ ...PACKAGED }, PACKAGED); + expect(out.label).toBe('客户'); + expect(out.pluralLabel).toBe('客户'); + expect(out.description).toBe('本组织为其交付项目的公司。'); + }); + + it("a code-shipped extension's scalars beat the catalog — all three", () => { + // The #8037 half: `objectExtensions` declares scalars that the fold + // applies (ADR-0029 D9.2) and the catalog used to discard. + const folded = { + ...PACKAGED, + label: 'Account (Success Overlay)', + pluralLabel: 'Accounts (Success Overlay)', + description: 'A company, with the success overlay applied.', + }; + const out = zh(folded, PACKAGED); + expect(out.label).toBe('Account (Success Overlay)'); + expect(out.pluralLabel).toBe('Accounts (Success Overlay)'); + expect(out.description).toBe('A company, with the success overlay applied.'); + }); + + it("a tenant's own rename beats the catalog — the severe half", () => { + // The `PUT /meta/object/:name` → 200 case. Nothing distinguishes it from + // the extension case at this seam, deliberately: one mechanism, both. + expect(zh({ ...PACKAGED, label: 'Customer' }, PACKAGED).label).toBe('Customer'); + }); + + it('judges each scalar SEPARATELY', () => { + // A renamed `label` must not drag `pluralLabel`/`description` out of the + // catalog with it — three scalars, three comparisons. + const out = zh({ ...PACKAGED, label: 'Customer' }, PACKAGED); + expect(out.label).toBe('Customer'); + expect(out.pluralLabel).toBe('客户'); + expect(out.description).toBe('本组织为其交付项目的公司。'); + }); + + it('withholds the catalog in the SOURCE locale too, not just in translation', () => { + // An `en` session read the packaged English string back over the tenant's + // rename — the same defect, and the one an all-Chinese fixture cannot see. + const out = translateObject({ ...PACKAGED, label: 'Customer' }, BUNDLE, { + locale: 'en', + packagedBase: PACKAGED, + }); + expect(out.label).toBe('Customer'); + }); + + it('NO packaged base supplied → pre-#8284 behaviour, catalog applies', () => { + // "Unknown" is not "authored". A host whose protocol cannot answer (or a + // runtime-authored object with no code owner) keeps every translation it + // has today rather than losing them to an inference nobody can support. + expect(zh({ ...PACKAGED, label: 'Customer' }).label).toBe('客户'); + expect(zh({ ...PACKAGED, label: 'Customer' }, undefined).label).toBe('客户'); + expect(zh({ ...PACKAGED, label: 'Customer' }, null).label).toBe('客户'); + }); + + it('the ruled edge — renaming to exactly the base value degrades to a no-op', () => { + // Ruled harmless on 2026-08-13: the tenant typed the packaged word, so the + // catalog still translates it. Pinned because it is a DECISION, not an + // oversight — a later reader must not "fix" it. + expect(zh({ ...PACKAGED, label: 'Account' }, PACKAGED).label).toBe('客户'); + }); + + it('a base that declares no such scalar still counts as a divergence', () => { + // The package shipped no `description`; the served document has one, so it + // came from an extension or an overlay — authored either way. + const base = { name: 'showcase_account', label: 'Account' }; + const out = zh({ ...base, description: 'Authored after the fact.' }, base); + expect(out.description).toBe('Authored after the fact.'); + // …and the scalar that DID come from the package is still translated. + expect(out.label).toBe('客户'); + }); + + it('an absent or empty document scalar is not an override', () => { + // Nothing to protect: the catalog is the only value on offer, so it is + // still served (this is the `?? doc.label` half of the old expression). + expect(zh({ name: 'showcase_account' }, PACKAGED).label).toBe('客户'); + expect(zh({ name: 'showcase_account', label: '' }, PACKAGED).label).toBe('客户'); + }); + + it('leaves FIELD labels alone — the rule is scoped to the three scalars', () => { + // Field labels have their own per-field catalog keys and no measured + // divergence; widening the rule to them would be a second change riding + // this one. + const withFields = { + ...PACKAGED, + label: 'Customer', + fields: { industry: { name: 'industry', type: 'select', label: 'Vertical' } }, + }; + const bundle: TranslationBundle = { + 'zh-CN': { + objects: { + showcase_account: { label: '客户', fields: { industry: { label: '行业' } } }, + }, + } as any, + }; + const out = translateObject(withFields, bundle, { locale: 'zh-CN', packagedBase: PACKAGED }); + expect(out.label).toBe('Customer'); + expect((out.fields as any).industry.label).toBe('行业'); + }); + + it('does not mutate either input document', () => { + const doc = { ...PACKAGED, label: 'Customer' }; + zh(doc, PACKAGED); + expect(doc.label).toBe('Customer'); + expect(PACKAGED.label).toBe('Account'); + }); + + it('reaches the generic dispatcher — translateMetadataDocument carries the base', () => { + // `@objectstack/rest` never calls `translateObject` directly; it goes + // through the type dispatch, so a base that stopped at the dispatcher + // would leave the defect exactly where it was. + const out = translateMetadataDocument( + 'object', + { ...PACKAGED, label: 'Customer' }, + BUNDLE, + { locale: 'zh-CN', packagedBase: PACKAGED }, + ); + expect(out.label).toBe('Customer'); + expect(out.pluralLabel).toBe('客户'); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 7e3badd4d1..960b337556 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -124,6 +124,34 @@ export interface ResolveOptions { fallbackChain?: string[]; } +/** + * [#8284] Options for the metadata-DOCUMENT translators + * ({@link translateMetadataDocument} and the per-type functions it dispatches + * to), adding the packaged base document the catalog-vs-explicit-override rule + * compares against. + * + * Separate from {@link ResolveOptions} because the per-attribute resolvers + * (`resolveViewLabel`, `resolveActionLabel`, …) answer "what does the bundle + * say" and have no document to compare anything to. Only a translator holding + * a whole document can ask whether that document still carries what the + * package shipped. + */ +export interface TranslateDocumentOptions extends ResolveOptions { + /** + * The PACKAGED (code-layer) base document this one was resolved from — the + * owner's own declaration, BEFORE `objectExtensions` are folded on + * (ADR-0029 D9.2) and before any tenant `sys_metadata` overlay. + * + * Supplied by the serving layer, which is the only one that knows it; when + * it is absent the translators behave exactly as they did before #8284 (the + * catalog applies unconditionally), because "no baseline known" is not + * evidence that a value was authored. + * + * See {@link translateObject} for the rule it feeds. + */ + packagedBase?: unknown; +} + /** * Resolve a requested locale code against the locales actually present in a * bundle, applying BCP-47 fallback so callers that pass a base language @@ -543,7 +571,7 @@ export function translateAction( */ const METADATA_DOCUMENT_TRANSLATORS: Record< string, - (doc: any, bundle: TranslationBundle | undefined, opts?: ResolveOptions) => any + (doc: any, bundle: TranslationBundle | undefined, opts?: TranslateDocumentOptions) => any > = { view: translateView, action: translateAction, @@ -569,13 +597,15 @@ export const TRANSLATABLE_METADATA_TYPES: ReadonlySet = new Set( * @param type Canonical metadata type string (see `MetadataTypeSchema`). * @param doc The metadata document to translate. * @param bundle Translation bundle (typically loaded from the i18n service). - * @param opts Locale + fallback chain. + * @param opts Locale + fallback chain, plus the packaged base document + * ({@link TranslateDocumentOptions.packagedBase}) when the serving layer can + * supply it. */ export function translateMetadataDocument( type: string, doc: any, bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + opts?: TranslateDocumentOptions, ): any { if (!doc || typeof doc !== 'object') return doc; const translate = METADATA_DOCUMENT_TRANSLATORS[type]; @@ -1362,6 +1392,40 @@ function builtinSystemFieldLabel( return undefined; } +/** + * [#8284] Whether one of an object document's three scalars has been + * EXPLICITLY set — i.e. it no longer carries the value the package shipped. + * + * This is comparison-based provenance, and it is the whole mechanism: no flag + * is carried through the fold, nothing is stamped on the document, and the + * question is answered from the two values alone. A scalar that still equals + * the packaged base is a packaged default (the catalog is its translation); a + * scalar that differs was authored by somebody — a code-shipped + * `objectExtensions` overlay, or the tenant's own Studio rename — and the + * catalog, which translates the packaged default, has nothing to say about it. + * + * Deliberately conservative in three ways, so this can only ever WITHHOLD the + * catalog from a value that provably diverged: + * + * - no base document supplied → `false` (the serving layer could not answer; + * "unknown" is not "authored", and every pre-#8284 caller keeps its + * behaviour); + * - a non-string / empty document value → `false` (there is nothing to + * protect, and the catalog is still the best answer available); + * - equality is exact. The ruled edge — a tenant renaming an object to + * exactly the packaged string — is a no-op: the catalog still applies, and + * the tenant sees the packaged translation of the word they typed. + */ +function scalarOverridesPackagedBase( + base: unknown, + key: 'label' | 'pluralLabel' | 'description', + value: unknown, +): boolean { + if (!base || typeof base !== 'object') return false; + if (typeof value !== 'string' || value.length === 0) return false; + return (base as Record)[key] !== value; +} + /** * Apply the active locale to an object metadata document. Translates the * object's `label` / `pluralLabel` / `description`, walks each field to @@ -1380,11 +1444,34 @@ function builtinSystemFieldLabel( * for them. The Console papered over it by re-resolving labels client-side * against a separately fetched bundle, which left every other consumer — mobile, * plain HTTP, SDUI — rendering the source language (#3370). + * + * ## [#8284] The catalog LOSES to an explicit override + * + * The three scalars are not resolved as a flat `catalog ?? document`. The + * catalog is keyed by object name and is the packaged translation of the + * PACKAGED declaration, so consulting it first overwrote every value that had + * been authored on top of that declaration — a code-shipped `objectExtensions` + * scalar, and (the severe half) the tenant's own Studio rename, which answered + * `200` and then reached neither `GET /meta/object` nor + * `GET /meta/object/:name`, i.e. neither read a writable form derives from. + * One object served three labels, and the only surface showing the saved value + * was `?layers=true`, documented as a diagnostic. + * + * Maintainer ruling (2026-08-13): a tenant's explicit scalar is authored data + * and must win, decided by COMPARISON — the catalog applies only while the + * document's scalar still equals {@link TranslateDocumentOptions.packagedBase}'s. + * See {@link scalarOverridesPackagedBase} for the exact test and its three + * conservative edges. `?layers=true` stays untranslated and diagnostic, + * unchanged. + * + * The rule is scoped to the three SCALARS, which are what the ruling covers: + * `fields` is a key-keyed spread whose per-field labels have their own + * (per-field) catalog keys, and no read has been measured to diverge on them. */ export function translateObject( doc: T, bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + opts?: TranslateDocumentOptions, ): T { if (!doc || typeof doc !== 'object') return doc; const objectName = doc.name; @@ -1392,11 +1479,20 @@ export function translateObject( // still apply (custom objects typically ship no translation entries). if (!objectName) return doc; - const label = lookupObjectField(bundle, objectName, 'label', opts) ?? doc.label; - const pluralLabel = - lookupObjectField(bundle, objectName, 'pluralLabel', opts) ?? doc.pluralLabel; - const description = - lookupObjectField(bundle, objectName, 'description', opts) ?? doc.description; + // [#8284] `catalog ?? document`, EXCEPT where the document's own value has + // diverged from the packaged declaration — there the document is authored + // data and the catalog yields to it. + const resolveScalar = ( + key: 'label' | 'pluralLabel' | 'description', + authored: string | undefined, + ): string | undefined => + scalarOverridesPackagedBase(opts?.packagedBase, key, authored) + ? authored + : lookupObjectField(bundle, objectName, key, opts) ?? authored; + + const label = resolveScalar('label', doc.label); + const pluralLabel = resolveScalar('pluralLabel', doc.pluralLabel); + const description = resolveScalar('description', doc.description); const translateField = (name: string, def: ObjectFieldLike): ObjectFieldLike => { const next: ObjectFieldLike = { ...def };