From 7976cc1a01aeab0307fe5a26b1617edc50fec5e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:18:39 +0000 Subject: [PATCH 1/7] test(i18n): pin a tenant-rewritten rule message on the refusal and on /meta (red) ADR-0029 D9.2a: under a non-default locale the packaged translation of a validation rule message must not replace a tenant's rewrite of it. Red first, on both readers: translateObject's validations overlay, the rule evaluator's authored message, and the two together off one registry. Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc Co-authored-by: Claude --- .../engine-validation-tenant-rewrite.test.ts | 208 ++++++++++++++++++ .../src/validation/rule-validator.test.ts | 80 +++++++ .../i18n-resolver.object-validations.test.ts | 73 ++++++ 3 files changed, 361 insertions(+) create mode 100644 packages/objectql/src/engine-validation-tenant-rewrite.test.ts diff --git a/packages/objectql/src/engine-validation-tenant-rewrite.test.ts b/packages/objectql/src/engine-validation-tenant-rewrite.test.ts new file mode 100644 index 00000000000..f12a7f23b23 --- /dev/null +++ b/packages/objectql/src/engine-validation-tenant-rewrite.test.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0029 D9.2a — a tenant's rewrite of a packaged rule message beats the + * packaged translation, on BOTH readers of the rule, and the two still agree. + * + * A packaged object declares two rules and ships their `zh-CN` translations at + * `objects.._validations..message`. A tenant overlays the object + * (ADR-0029 D9) and rewrites one of the two messages, keeping the rule's name. + * The catalog translates the PACKAGED rule, so under `zh-CN`: + * + * - the rewritten rule must answer the tenant's own sentence, as written; + * - the rule the tenant left alone must still answer its translation. + * + * Both readers are driven off ONE real registry: the refusal of a real write + * (the engine's validator), and the served object as `/meta` translates it — + * `translateObject` over the resolved object, with the packaged base the + * serving layer hands it (`getPackagedObjectBase`, the accessor `/meta` reads + * through `packagedObjectBaseOf`). For every header they must be one string. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { preferredLocaleFromHeader, translateObject } from '@objectstack/spec/system'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectQL } from './index.js'; + +const OBJ = 'tr_duty'; +const PKG = 'com.objectstack.test.tenant-rewrite'; + +const PACKAGED = { + returned_needs_note: 'Say why the duty is being returned.', + done_needs_owner: 'A finished duty needs an owner.', +}; +const ZH = { + returned_needs_note: '请写明打回的原因。', + done_needs_owner: '已完成的职责需要负责人。', +}; +const REWRITE = 'Tell the owner what to change before you send it back.'; + +const rules = (returnedMessage: string) => [ + { + type: 'script', + name: 'returned_needs_note', + message: returnedMessage, + condition: "record.status == 'returned' && record.return_note == null", + fields: ['return_note'], + }, + { + type: 'script', + name: 'done_needs_owner', + message: PACKAGED.done_needs_owner, + condition: "record.status == 'done' && record.owner == null", + fields: ['owner'], + }, +]; + +const body = (returnedMessage: string) => ({ + name: OBJ, + label: 'Duty', + fields: { + status: { type: 'text', label: 'Status' }, + return_note: { type: 'text', label: 'Return note' }, + owner: { type: 'text', label: 'Owner' }, + }, + validations: rules(returnedMessage), +}); + +const BUNDLE: Record = { + en: { + objects: { + [OBJ]: { + _validations: { + returned_needs_note: { message: PACKAGED.returned_needs_note }, + done_needs_owner: { message: PACKAGED.done_needs_owner }, + }, + }, + }, + }, + 'zh-CN': { + objects: { + [OBJ]: { + _validations: { + returned_needs_note: { message: ZH.returned_needs_note }, + done_needs_owner: { message: ZH.done_needs_owner }, + }, + }, + }, + }, +}; + +/** `FileI18nAdapter.t()`-shaped: the locale exactly, then the fallback, then the key echoed. */ +function servedI18n(bundle: Record, fallbackLocale = 'en') { + const dig = (data: unknown, key: string): unknown => + key.split('.').reduce( + (cur, part) => (cur && typeof cur === 'object' ? (cur as any)[part] : undefined), + data, + ); + return { + t(key: string, locale: string): string { + const exact = dig(bundle[locale], key); + if (typeof exact === 'string') return exact; + const fallback = dig(bundle[fallbackLocale], key); + return typeof fallback === 'string' ? fallback : key; + }, + getLocales: () => Object.keys(bundle), + }; +} + +function makeDriver() { + const rows = new Map(); + return { + name: 'com.objectstack.driver.memory.tr', + version: '1.0.0', + async connect() { /* noop */ }, + async disconnect() { /* noop */ }, + async initObjects() { /* noop */ }, + async find() { return [...rows.values()]; }, + async findOne(_o: string, options?: any) { return rows.get(String(options?.where?.id)) ?? null; }, + async count() { return rows.size; }, + async create(_o: string, data: any) { rows.set(String(data.id), data); return data; }, + async update(_o: string, data: any) { return data; }, + async delete() { return false; }, + } as any; +} + +describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on /meta', () => { + let engine: ObjectQL; + let protocol: any; + + /** Boot the packaged object, then (optionally) the tenant's overlay layer. */ + async function boot(overlay: boolean) { + engine = new ObjectQL(); + engine.registerDriver(makeDriver(), true); + await engine.init(); + engine.setI18nService(servedI18n(BUNDLE) as any); + engine.registry.registerObject(body(PACKAGED.returned_needs_note) as any, PKG); + if (overlay) { + engine.registry.registerObject({ ...body(REWRITE), _provenance: 'org' } as any, PKG, undefined, 'overlay'); + } + protocol = new ObjectStackProtocolImplementation(engine as any); + } + + afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + async function refusal(header: string, data: Record): Promise { + try { + await engine.insert(OBJ, data, { context: { locale: preferredLocaleFromHeader(header) } } as any); + } catch (e: any) { + return String(e?.fields?.[0]?.message ?? e?.message); + } + throw new Error('expected the write to be rejected'); + } + + function served(header: string): Record { + const doc = translateObject(engine.registry.getObject(OBJ) as any, BUNDLE as any, { + locale: preferredLocaleFromHeader(header), + fallbackChain: ['en'], + packagedBase: protocol.getPackagedObjectBase(OBJ), + }) as any; + return Object.fromEntries(doc.validations.map((r: any) => [r.name, r.message])); + } + + async function table() { + const out: Record = {}; + for (const header of ['zh-CN', 'zh', 'en']) { + out[header] = { + served: served(header), + refusal: { + returned_needs_note: await refusal(header, { id: 'r1', status: 'returned' }), + done_needs_owner: await refusal(header, { id: 'r2', status: 'done' }), + }, + }; + } + return out; + } + + describe('with the tenant overlay', () => { + beforeEach(async () => { await boot(true); }); + + it('the overlay is what both readers see, over a packaged owner that is still there', () => { + expect((engine.registry.getObject(OBJ) as any).validations[0].message).toBe(REWRITE); + expect((protocol.getPackagedObjectBase(OBJ) as any).validations[0].message).toBe(PACKAGED.returned_needs_note); + }); + + it('serves and refuses with the rewrite as written, and still translates the untouched rule', async () => { + const both = (zh: string) => ({ returned_needs_note: REWRITE, done_needs_owner: zh }); + expect(await table()).toEqual({ + 'zh-CN': { served: both(ZH.done_needs_owner), refusal: both(ZH.done_needs_owner) }, + zh: { served: both(ZH.done_needs_owner), refusal: both(ZH.done_needs_owner) }, + en: { served: both(PACKAGED.done_needs_owner), refusal: both(PACKAGED.done_needs_owner) }, + }); + }); + }); + + describe('control: no overlay', () => { + beforeEach(async () => { await boot(false); }); + + it('translates both packaged rules on both readers', async () => { + expect(await table()).toEqual({ + 'zh-CN': { served: ZH, refusal: ZH }, + zh: { served: ZH, refusal: ZH }, + en: { served: PACKAGED, refusal: PACKAGED }, + }); + }); + }); +}); diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index 85f72e8b407..54e98ea1897 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -2649,3 +2649,83 @@ describe('evaluateValidationRules — authored rule messages resolve from the bu expect(err.message).not.toBe('这条不该出现。'); }); }); + +/** + * ADR-0029 D9.2a — a tenant's rewrite of a rule message beats the packaged + * translation in the refusal. + * + * The catalog entry at `objects.._validations..message` translates + * the PACKAGED rule. When the served rule's message diverged from the packaged + * rule of the same name, the sentence is the tenant's own and is returned as + * written, judged by `scalarOverridesPackagedBase` (the one comparison `/meta` + * judges the same message by). `messages.packagedRule` is the engine's lookup + * into the packaged owner's declaration; absent means no packaged base is + * known, and the catalog applies as it always did. + */ +describe('evaluateValidationRules — a tenant-rewritten rule message beats the packaged translation (ADR-0029 D9.2a)', () => { + const PACKAGED = 'A standing duty must declare a frequency.'; + const REWRITE = 'Pick how often this duty repeats.'; + const ZH = '常规任务必须设置频率。'; + const KEY = 'zh-CN|objects.duly_duty._validations.standing_no_frequency.message'; + const t = (key: string, locale: string) => ({ [KEY]: ZH } as Record)[`${locale}|${key}`] ?? key; + + const failing = (schema: any, data: Record, opts: Record) => { + try { + evaluateValidationRules(schema, data, 'insert', opts); + } catch (e) { + return (e as ValidationError).fields; + } + throw new Error('expected a ValidationError'); + }; + + const scriptRule = (message: string) => ({ + type: 'script', + name: 'standing_no_frequency', + message, + condition: "record.frequency == null || record.frequency == ''", + fields: ['frequency'], + }); + const schemaWith = (message: string) => ({ + fields: { frequency: { type: 'select', label: '频率' } }, + validations: [scriptRule(message)], + }); + const packagedRule = (name: string) => (name === 'standing_no_frequency' ? scriptRule(PACKAGED) : undefined); + const messages = (extra: Record = {}) => + ({ locale: 'zh-CN', objectName: 'duly_duty', translate: t, ...extra }); + + it('returns the rewrite as written in a non-default locale', () => { + const [err] = failing(schemaWith(REWRITE), {}, { messages: messages({ packagedRule }) }); + expect(err.message).toBe(REWRITE); + expect(err.code).toBe('rule_violation'); + expect(err.field).toBe('frequency'); + }); + + it('still translates a rule whose message is the packaged one', () => { + const [err] = failing(schemaWith(PACKAGED), {}, { messages: messages({ packagedRule }) }); + expect(err.message).toBe(ZH); + }); + + it('counts a rule the packaged object does not declare as authored', () => { + const [err] = failing(schemaWith(REWRITE), {}, { messages: messages({ packagedRule: () => undefined }) }); + expect(err.message).toBe(REWRITE); + }); + + it('infers nothing without a packaged base: the catalog applies, as before', () => { + const [err] = failing(schemaWith(REWRITE), {}, { messages: messages() }); + expect(err.message).toBe(ZH); + }); + + it('judges a conditional branch against the packaged branch of the same name', () => { + const conditional = (branchMessage: string) => ({ + type: 'conditional', + name: 'standing_gate', + message: 'wrapper', + when: 'true', + then: { ...scriptRule(branchMessage) }, + }); + const packaged = (name: string) => (name === 'standing_no_frequency' ? scriptRule(PACKAGED) : undefined); + const schema = (m: string) => ({ fields: { frequency: { type: 'select' } }, validations: [conditional(m)] }); + expect(failing(schema(REWRITE), {}, { messages: messages({ packagedRule: packaged }) })[0].message).toBe(REWRITE); + expect(failing(schema(PACKAGED), {}, { messages: messages({ packagedRule: packaged }) })[0].message).toBe(ZH); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.object-validations.test.ts b/packages/spec/src/system/i18n-resolver.object-validations.test.ts index 876897db7da..1561e3d3d32 100644 --- a/packages/spec/src/system/i18n-resolver.object-validations.test.ts +++ b/packages/spec/src/system/i18n-resolver.object-validations.test.ts @@ -253,3 +253,76 @@ describe('#27135 the fields and actions overlays are unchanged', () => { expect('validations' in out).toBe(false); }); }); + +/** + * ADR-0029 D9.2a — the catalog LOSES to a tenant's rewrite of a rule message. + * + * The catalog translates the PACKAGED rule, so a served message that diverged + * from the packaged rule of the same name is the tenant's own sentence and is + * served as written, in every locale. The comparison is the one every other + * string on the object is judged by (`valueOverridesPackagedBase`), against + * the packaged object the serving layer hands over as `packagedBase`. The + * refusal of the same write applies the same rule beside the evaluator, in + * objectql's `engine-validation-tenant-rewrite`. + */ +describe('ADR-0029 D9.2a a tenant-rewritten rule message beats the packaged translation', () => { + const REWRITE = { + closed_is_final: 'Closed tickets stay closed — open a new one instead.', + vip_branch: 'Every VIP ticket needs a named owner.', + }; + + /** The tenant's overlay: two messages rewritten, one rule of its own. */ + const tenantDoc = () => { + const doc = clone(DOC); + doc.validations[0].message = REWRITE.closed_is_final; + doc.validations[3].then.message = REWRITE.vip_branch; + doc.validations.push(rule({ + type: 'script', + name: 'tenant_only_rule', + message: 'Our own rule, in our own words.', + condition: 'record.owner == null', + })); + return doc; + }; + + /** A catalog entry for the tenant's own rule too: it still must not apply. */ + const bundle = TranslationBundleSchema.parse({ + 'zh-CN': localeData({ ...ZH, tenant_only_rule: '不该出现的译文。' }), + }); + + it('serves the rewrite as written, and still translates every rule the tenant left alone', () => { + const out = translateObject(tenantDoc(), bundle, { locale: 'zh-CN', packagedBase: clone(DOC) }) as any; + expect(messagesOf(out)).toEqual({ + closed_is_final: REWRITE.closed_is_final, + refund_needs_reason: ZH.refund_needs_reason, + untranslated_rule: AUTHORED.untranslated_rule, + wrapper: AUTHORED.wrapper, + vip_branch: REWRITE.vip_branch, + standard_branch: ZH.standard_branch, + }); + // A rule the packaged object does not declare was authored after the fact. + expect(out.validations[4].message).toBe('Our own rule, in our own words.'); + }); + + it('counts every named rule as authored when the packaged object declares none', () => { + const { validations: _dropped, ...bare } = clone(DOC); + const doc = tenantDoc(); + const out = translateObject(doc, bundle, { locale: 'zh-CN', packagedBase: bare }) as any; + expect(out.validations).toBe(doc.validations); + }); + + it('infers nothing without a packaged base: the catalog applies, as before', () => { + const out = translateObject(tenantDoc(), bundle, { locale: 'zh-CN' }) as any; + expect(out.validations[0].message).toBe(ZH.closed_is_final); + expect(out.validations[3].then.message).toBe(ZH.vip_branch); + }); + + it('is reached through the /meta door with the base the serving layer supplies', () => { + const out = translateMetadataDocument('object', tenantDoc(), bundle, { + locale: 'zh-CN', + packagedBase: clone(DOC), + }) as any; + expect(out.validations[0].message).toBe(REWRITE.closed_is_final); + expect(out.validations[1].message).toBe(ZH.refund_needs_reason); + }); +}); From 1a6b33057b098b9c81cda2cccae6a06ceda31a29 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:23:19 +0000 Subject: [PATCH 2/7] fix(i18n): a tenant-rewritten rule message beats the packaged translation on both readers ADR-0029 D9.2a. translateObjectValidations (the /meta overlay) and the rule evaluator's authoredRuleMessage (the refusal) now apply the catalog only while the served message still equals the packaged rule's of the same name, judged by the one predicate: valueOverridesPackagedBase, which objectql reaches through the exported scalarOverridesPackagedBase (its key union gains 'message'). The engine's validation message context carries the packaged owner's rule lookup. Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc Co-authored-by: Claude --- .../src/engine-validation-messages.ts | 74 ++++++++++++++++++- packages/objectql/src/engine.ts | 2 + .../src/validation/record-validator.ts | 15 ++++ .../objectql/src/validation/rule-validator.ts | 17 ++++- .../i18n-resolver.object-validations.test.ts | 4 +- packages/spec/src/system/i18n-resolver.ts | 69 ++++++++++++++--- 6 files changed, 166 insertions(+), 15 deletions(-) diff --git a/packages/objectql/src/engine-validation-messages.ts b/packages/objectql/src/engine-validation-messages.ts index 5d9194fa75b..ecc2c8ad7e1 100644 --- a/packages/objectql/src/engine-validation-messages.ts +++ b/packages/objectql/src/engine-validation-messages.ts @@ -33,6 +33,7 @@ * closure, which runs after `validationMessageContext` has returned and sees * whatever service `setI18nService` has installed by then, as it did. * `setI18nService` itself stays in the class: it is the field's setter. + * `registry` (ADR-0029 D9.2a, the packaged rule lookup) goes as a getter too. * * This module imports the engine only as a TYPE, so there is no module cycle * at run time, and nothing re-exports it, so the published surface does not @@ -40,6 +41,7 @@ */ import type { ObjectQL } from './engine.js'; +import type { ValidationMessageContext } from './validation/record-validator.js'; import { objectLabelKey, resolveBundleLocale } from '@objectstack/spec/system'; /** @@ -52,22 +54,88 @@ import { objectLabelKey, resolveBundleLocale } from '@objectstack/spec/system'; */ export interface ValidationMessageHost { readonly i18nService: ObjectQL['i18nService']; + readonly registry: ObjectQL['registry']; negotiatedMessageLocale: ObjectQL['negotiatedMessageLocale']; } -/** The body of `ObjectQL#validationMessageContext`; the method's docblock there is its contract. */ +/** + * The body of `ObjectQL#validationMessageContext`; the method's docblock there is its contract. + * + * [ADR-0029 D9.2a] It also carries `packagedRule` (`ValidationMessageContext`), + * the lookup the rule evaluator judges a tenant's rewrite of a rule message by, + * when the object has a packaged owner — see {@link packagedRuleLookup}. + */ export function validationMessageContext( - this: Pick, + this: Pick, objectName: string, // Only `locale` is read — narrower than `ExecutionContext` so both the // resolved and the input-shaped envelope satisfy it. context?: { locale?: string }, -): { locale?: string; translate?: (key: string, locale: string, params?: Record) => string; objectName: string } { +): ValidationMessageContext & { objectName: string } { const t = this.i18nService?.t; + const packagedRule = packagedRuleLookup(this.registry, objectName); return { objectName, locale: this.negotiatedMessageLocale(context?.locale), translate: t ? (key, locale, params) => t.call(this.i18nService, key, locale, params) : undefined, + ...(packagedRule !== undefined ? { packagedRule } : {}), + }; +} + +/** + * How deep {@link packagedRuleLookup} follows `conditional` branches — the + * bound `/meta`'s `translateObjectValidations` walks to, so both readers judge + * the same branches. + */ +const MAX_VALIDATION_DEPTH = 10; + +/** + * [ADR-0029 D9.2a] The packaged (code-shipped) rule of a name on `objectName`, + * for the rule evaluator's divergence test. + * + * The declaration is the packaged OWNER's (`getPackagedObjectOwner`, pre-fold), + * the same body `/meta` hands `translateObject` as `packagedBase` through + * `getPackagedObjectBase`, so both readers compare a served message against one + * packaged rule. Rules are keyed by `name`, each `conditional` branch under its + * own name; the first rule of a name wins, as on `/meta`. + * + * `undefined` — nothing inferred, the catalog applies — when no packaged owner + * is registered for the object, or the registry cannot answer (a partial + * double). The index is built on the first lookup, which a write that refuses + * nothing never makes. + */ +function packagedRuleLookup( + registry: ValidationMessageHost['registry'] | undefined, + objectName: string, +): ((ruleName: string) => unknown) | undefined { + if (typeof registry?.getPackagedObjectOwner !== 'function') return undefined; + let definition: unknown; + try { + definition = registry.getPackagedObjectOwner(objectName)?.definition; + } catch { + // A read over in-memory contributors must not turn a 400 into a 500. + return undefined; + } + if (!definition || typeof definition !== 'object') return undefined; + const validations = (definition as { validations?: unknown }).validations; + let byName: Map | undefined; + return (ruleName) => { + if (byName === undefined) { + const index = new Map(); + const visit = (raw: unknown, depth: number): void => { + if (!raw || typeof raw !== 'object' || depth >= MAX_VALIDATION_DEPTH) return; + const rule = raw as { type?: unknown; name?: unknown; then?: unknown; otherwise?: unknown }; + if (rule.type === 'conditional') { + visit(rule.then, depth + 1); + visit(rule.otherwise, depth + 1); + return; + } + if (typeof rule.name === 'string' && rule.name.length > 0 && !index.has(rule.name)) index.set(rule.name, rule); + }; + if (Array.isArray(validations)) for (const raw of validations) visit(raw, 0); + byName = index; + } + return byName.get(ruleName); }; } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 2b9a80b31b7..b63ecbb6df5 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -9545,8 +9545,10 @@ export class ObjectQL implements IObjectQLEngine { ): { locale?: string; translate?: (key: string, locale: string, params?: Record) => string; objectName: string } { // [#25522 cut 1] The body moved to `engine-validation-messages.ts` (see `ValidationMessageHost`). const readI18nService = () => this.i18nService; + const readRegistry = () => this._registry; const host: ThisParameterType = { get i18nService() { return readI18nService(); }, + get registry() { return readRegistry(); }, negotiatedMessageLocale: (...a) => this.negotiatedMessageLocale(...a), }; return validationMessageContext.call(host, objectName, context); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index f47f64b9f0e..6039e16ff35 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -404,6 +404,21 @@ export interface ValidationMessageContext { translate?: ValidationMessageTranslator; /** Object name — needed to address the field's label in the bundle. */ objectName?: string; + /** + * [ADR-0029 D9.2a] The PACKAGED (code-shipped) declaration of the rule of + * this name on `objectName` — a `conditional` branch addressed by its own + * name — read from the packaged owner's declaration, the base `/meta` hands + * its translator as `packagedBase`. An authored `rule.message` that diverged + * from it is a tenant's rewrite, and the catalog (the packaged rule's + * translation) is not applied to it. + * + * Absent when no packaged owner is registered for the object (a + * runtime-authored object, a registry-less host): nothing is inferred and + * the catalog applies, as on `/meta` with no base. Present, `undefined` + * means the packaged declaration carries no rule of that name, so the + * served rule was authored after the fact. + */ + packagedRule?: (ruleName: string) => unknown; } /** diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 51aa0041d03..20ee52e5b9a 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -243,7 +243,7 @@ import { recordAdvisoryHit } from '@objectstack/core'; // reports it unconditionally, even under `systemFields: false`), and the // engine's whole by-id addressing reads that key (`idAddressesThisRow`, // `addressKey: 'id'`). -import { SystemFieldName, objectValidationMessageKey } from '@objectstack/spec/system'; +import { SystemFieldName, objectValidationMessageKey, scalarOverridesPackagedBase } from '@objectstack/spec/system'; import Ajv, { type ValidateFunction } from 'ajv'; // #5029 — `format` is NOT built into ajv 8; it ships in this separate package. // See the `const ajv` note below for why the runtime registers it. @@ -3759,6 +3759,17 @@ export function emitValidationAdvisories( * * `rule.name` is the address — including a nested `conditional` branch's own * name, since the branch is the rule whose message the caller sees. + * + * ## The catalog loses to a tenant's rewrite — ADR-0029 D9.2a + * + * The catalog entry translates the PACKAGED rule. A message that diverged from + * the packaged rule of the same name (`messages.packagedRule`) is a tenant's + * rewrite and is returned as written, in every locale. The test is + * `scalarOverridesPackagedBase`, the one comparison `/meta`'s + * `translateObjectValidations` judges the same message by (⛔ never a second + * one here), so the served rule and the refusal of the same write keep + * showing one sentence. A rule the packaged declaration does not carry counts + * as diverged; no packaged rule lookup means nothing is inferred. */ function authoredRuleMessage( rule: Pick, @@ -3766,6 +3777,10 @@ function authoredRuleMessage( ): string { if (messages?.translate && messages.objectName && messages.locale && typeof rule.name === 'string' && rule.name.length > 0) { + if (messages.packagedRule + && scalarOverridesPackagedBase(messages.packagedRule(rule.name) ?? {}, 'message', rule.message)) { + return rule.message; + } const key = objectValidationMessageKey(messages.objectName, rule.name); try { const translated = messages.translate(key, messages.locale); diff --git a/packages/spec/src/system/i18n-resolver.object-validations.test.ts b/packages/spec/src/system/i18n-resolver.object-validations.test.ts index 1561e3d3d32..50c1103a94e 100644 --- a/packages/spec/src/system/i18n-resolver.object-validations.test.ts +++ b/packages/spec/src/system/i18n-resolver.object-validations.test.ts @@ -299,9 +299,9 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message beats the packaged tran wrapper: AUTHORED.wrapper, vip_branch: REWRITE.vip_branch, standard_branch: ZH.standard_branch, + // A rule the packaged object does not declare was authored after the fact. + tenant_only_rule: 'Our own rule, in our own words.', }); - // A rule the packaged object does not declare was authored after the fact. - expect(out.validations[4].message).toBe('Our own rule, in our own words.'); }); it('counts every named rule as authored when the packaged object declares none', () => { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 06ccb90986b..e97c4570453 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3852,10 +3852,21 @@ function builtinSystemFieldLabel( * * ⛔ The three conservative edges above are therefore load-bearing in two * places. Widening any of them widens the fold's precedence rule too. + * + * ## `message` — a validation rule's, judged on both of its readers + * + * A rule's `message` is served on `/meta` ({@link translateObjectValidations}) + * and returned in the refusal of a write by `@objectstack/objectql`'s rule + * evaluator, and both apply the catalog only while the served message still + * equals the packaged rule's of the same name (ADR-0029 D9.2a). The evaluator + * asks through this export, with the packaged RULE as `base`, so a tenant's + * rewrite is judged by one comparison on both readers and the two keep + * answering one sentence. The key is the only thing it adds: the three edges + * above hold for it unchanged. */ export function scalarOverridesPackagedBase( base: unknown, - key: 'label' | 'pluralLabel' | 'description', + key: 'label' | 'pluralLabel' | 'description' | 'message', value: unknown, ): boolean { return valueOverridesPackagedBase(base, key, value); @@ -3985,21 +3996,30 @@ const MAX_VALIDATION_DEPTH = 10; * branches are translated and the wrapper's `message` is left as authored, the * way the extractor leaves it unscaffolded. * - * ## No packaged-base comparison - * - * Unlike the object's scalars, a rule message is a flat `catalog ?? document`, - * as a field `label` is. The evaluator translates a refusal without consulting - * any packaged base, and a divergence rule applied here alone would serve one - * sentence on `/meta` and another in the refusal of the same write, which is - * the split this overlay exists to close. + * ## The catalog loses to a tenant's rewrite — ADR-0029 D9.2a + * + * The catalog translates the PACKAGED rule, so a served message that diverged + * from the packaged rule of the same name — a tenant overlay that kept the + * rule and reworded it — is authored data and keeps its text, judged by + * {@link valueOverridesPackagedBase} (⛔ never a second comparison) against + * the rule in {@link TranslateDocumentOptions.packagedBase}'s `validations`, + * a `conditional` branch by its own name. A rule the base does not declare was + * authored after the fact, and its message counts as diverged; no base + * supplied means nothing is inferred and the catalog applies. + * + * The evaluator applies the same rule to its refusal, through the exported + * {@link scalarOverridesPackagedBase} and the packaged owner's declaration the + * engine hands it, so `/meta` and the refusal of the same write still show one + * sentence for one rule and locale. */ function translateObjectValidations( validations: unknown, bundle: TranslationBundle | undefined, objectName: string, - opts: ResolveOptions | undefined, + opts: TranslateDocumentOptions | undefined, ): unknown { if (!Array.isArray(validations) || !bundle) return validations; + const packaged = packagedRulesByName(opts?.packagedBase); const translateRule = (raw: unknown, depth: number): unknown => { const rule = asRecord(raw); if (rule === undefined || depth >= MAX_VALIDATION_DEPTH) return raw; @@ -4014,6 +4034,9 @@ function translateObjectValidations( }; } if (typeof rule.name !== 'string' || rule.name.length === 0) return raw; + if (packaged !== undefined && valueOverridesPackagedBase(packaged.get(rule.name) ?? {}, 'message', rule.message)) { + return raw; + } const path = objectValidationMessageKey(objectName, rule.name).split('.'); for (const code of localeChain(opts)) { const candidate = path.reduce( @@ -4035,6 +4058,32 @@ function translateObjectValidations( return changed ? next : validations; } +/** + * The packaged object's validation rules by `name`, each `conditional` branch + * under its own name to {@link MAX_VALIDATION_DEPTH}, for + * {@link translateObjectValidations}' divergence test. `undefined` when no + * base is known; an empty map when the base declares no rules, so every rule + * on the served document then counts as authored. The first rule of a name + * wins. + */ +function packagedRulesByName(packagedBase: unknown): Map> | undefined { + const base = asRecord(packagedBase); + if (base === undefined) return undefined; + const byName = new Map>(); + const visit = (raw: unknown, depth: number): void => { + const rule = asRecord(raw); + if (rule === undefined || depth >= MAX_VALIDATION_DEPTH) return; + if (rule.type === 'conditional') { + visit(rule.then, depth + 1); + visit(rule.otherwise, depth + 1); + return; + } + if (typeof rule.name === 'string' && rule.name.length > 0 && !byName.has(rule.name)) byName.set(rule.name, rule); + }; + if (Array.isArray(base.validations)) for (const raw of base.validations) visit(raw, 0); + return byName; +} + /** * Apply the active locale to an object metadata document. Translates the * object's `label` / `pluralLabel` / `description`, walks each field to @@ -4091,6 +4140,8 @@ function translateObjectValidations( * application of the same rule (ADR-0029 D9.2a, as {@link translateView} * applies it), judged per view against the packaged object's own `listViews` — * see {@link translateObjectListViews}. + * Each `validations[].message` follows it too, judged against the packaged + * rule of the same name — see {@link translateObjectValidations}. * * ## A field's translated help is served on `description` — ADR-0029 D9.2a * From 431a7c2d213bacda6d86471934bdab7e3e8d26d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:26:24 +0000 Subject: [PATCH 3/7] chore(changeset): a tenant's rewritten rule message wins on both readers Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc Co-authored-by: Claude --- .../27221-tenant-rewritten-rule-message-wins.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/27221-tenant-rewritten-rule-message-wins.md diff --git a/.changeset/27221-tenant-rewritten-rule-message-wins.md b/.changeset/27221-tenant-rewritten-rule-message-wins.md new file mode 100644 index 00000000000..f2a1bddbc67 --- /dev/null +++ b/.changeset/27221-tenant-rewritten-rule-message-wins.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +--- + +fix(i18n): a tenant's rewrite of a packaged validation rule message is served as written in every locale, on the refusal and on `/meta` (#27221) + +Clause-②: no +Metadata-protocol: semantic + +A package's bundle translates the rule messages that package shipped (`objects.._validations..message`). When a tenant overlaid a packaged object and reworded one of its validation rule messages, keeping the rule's name, a reader in any locale the bundle carries got the package's translation instead of the tenant's sentence. The rejected write's error and the rule served by `/meta` (and the public form) both showed it. + +- **Now.** Both readers apply the bundle entry only while the served message still equals the packaged rule's message of the same name. A message that differs is the tenant's own and is served and returned as written, in every locale. Rules the tenant left alone are still translated. A `conditional` branch is matched by its own name. A rule the package never shipped counts as the tenant's own, so a bundle entry for it no longer applies once the object has a packaged owner. With no packaged owner (an object authored entirely at runtime), nothing changes. +- **One rule, both readers.** `/meta` (`translateObject`) and the rule evaluator use the same comparison, the one already applied to an object's `label`, `description`, embedded list views and fields. `/meta` and the refusal of the same write still show one sentence for one rule and locale. +- **`scalarOverridesPackagedBase`** (`@objectstack/spec/system`) now also accepts the key `'message'`, so the rule evaluator judges a rule message through it. Its behaviour for the existing keys is unchanged. +- **`evaluateValidationRules`'s `messages` option** (`EvaluateRulesOptions.messages`, `@objectstack/objectql`) gains an optional `packagedRule` lookup, which the engine fills from the object's packaged owner. A caller that passes its own `messages` without it keeps the previous behaviour. From 9ba11607635b5de718e851b8cc8e01c871c6b781 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:31:03 +0000 Subject: [PATCH 4/7] fix(i18n): a rule the packaged owner does not declare keeps its translation A rule an objectExtensions entry folds onto a packaged object is absent from the owner's declaration by construction and is translated under its own name. Counting it as diverged served it untranslated on both readers. A rule with no packaged counterpart now has nothing to diverge from, as with no base at all, and the catalog applies. Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc Co-authored-by: Claude --- .../engine-validation-tenant-rewrite.test.ts | 46 +++++++++++++++---- .../src/validation/record-validator.ts | 8 ++-- .../src/validation/rule-validator.test.ts | 8 ++-- .../objectql/src/validation/rule-validator.ts | 7 ++- .../i18n-resolver.object-validations.test.ts | 26 ++++++----- packages/spec/src/system/i18n-resolver.ts | 28 +++++------ 6 files changed, 79 insertions(+), 44 deletions(-) diff --git a/packages/objectql/src/engine-validation-tenant-rewrite.test.ts b/packages/objectql/src/engine-validation-tenant-rewrite.test.ts index f12a7f23b23..e57f9cdf54f 100644 --- a/packages/objectql/src/engine-validation-tenant-rewrite.test.ts +++ b/packages/objectql/src/engine-validation-tenant-rewrite.test.ts @@ -10,7 +10,10 @@ * The catalog translates the PACKAGED rule, so under `zh-CN`: * * - the rewritten rule must answer the tenant's own sentence, as written; - * - the rule the tenant left alone must still answer its translation. + * - the rule the tenant left alone must still answer its translation; + * - a rule a second package folds on through `objectExtensions` — absent + * from the owner's declaration by construction — must still answer its own + * package's translation: it has no packaged message to diverge from. * * Both readers are driven off ONE real registry: the refusal of a real write * (the engine's validator), and the served object as `/meta` translates it — @@ -26,14 +29,29 @@ import { ObjectQL } from './index.js'; const OBJ = 'tr_duty'; const PKG = 'com.objectstack.test.tenant-rewrite'; +const EXT_PKG = 'com.objectstack.test.tenant-rewrite-extension'; const PACKAGED = { returned_needs_note: 'Say why the duty is being returned.', done_needs_owner: 'A finished duty needs an owner.', + ext_blocked_needs_reason: 'Say what is blocking the duty.', }; const ZH = { returned_needs_note: '请写明打回的原因。', done_needs_owner: '已完成的职责需要负责人。', + ext_blocked_needs_reason: '请写明阻塞原因。', +}; + +/** The extension package's own rule, folded onto the object. */ +const EXTENSION = { + name: OBJ, + validations: [{ + type: 'script', + name: 'ext_blocked_needs_reason', + message: PACKAGED.ext_blocked_needs_reason, + condition: "record.status == 'blocked' && record.return_note == null", + fields: ['return_note'], + }], }; const REWRITE = 'Tell the owner what to change before you send it back.'; @@ -72,6 +90,7 @@ const BUNDLE: Record = { _validations: { returned_needs_note: { message: PACKAGED.returned_needs_note }, done_needs_owner: { message: PACKAGED.done_needs_owner }, + ext_blocked_needs_reason: { message: PACKAGED.ext_blocked_needs_reason }, }, }, }, @@ -82,6 +101,7 @@ const BUNDLE: Record = { _validations: { returned_needs_note: { message: ZH.returned_needs_note }, done_needs_owner: { message: ZH.done_needs_owner }, + ext_blocked_needs_reason: { message: ZH.ext_blocked_needs_reason }, }, }, }, @@ -134,6 +154,7 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on await engine.init(); engine.setI18nService(servedI18n(BUNDLE) as any); engine.registry.registerObject(body(PACKAGED.returned_needs_note) as any, PKG); + engine.registry.registerObject(EXTENSION as any, EXT_PKG, undefined, 'extend'); if (overlay) { engine.registry.registerObject({ ...body(REWRITE), _provenance: 'org' } as any, PKG, undefined, 'overlay'); } @@ -170,6 +191,7 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on refusal: { returned_needs_note: await refusal(header, { id: 'r1', status: 'returned' }), done_needs_owner: await refusal(header, { id: 'r2', status: 'done' }), + ext_blocked_needs_reason: await refusal(header, { id: 'r3', status: 'blocked' }), }, }; } @@ -180,16 +202,24 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on beforeEach(async () => { await boot(true); }); it('the overlay is what both readers see, over a packaged owner that is still there', () => { + const served = (engine.registry.getObject(OBJ) as any).validations.map((r: any) => r.name); + expect(served).toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']); expect((engine.registry.getObject(OBJ) as any).validations[0].message).toBe(REWRITE); - expect((protocol.getPackagedObjectBase(OBJ) as any).validations[0].message).toBe(PACKAGED.returned_needs_note); + const packaged = protocol.getPackagedObjectBase(OBJ) as any; + expect(packaged.validations.map((r: any) => r.name)).toEqual(['returned_needs_note', 'done_needs_owner']); + expect(packaged.validations[0].message).toBe(PACKAGED.returned_needs_note); }); - it('serves and refuses with the rewrite as written, and still translates the untouched rule', async () => { - const both = (zh: string) => ({ returned_needs_note: REWRITE, done_needs_owner: zh }); + it('serves and refuses with the rewrite as written, and still translates the untouched and extension rules', async () => { + const rows = (t: typeof ZH) => ({ + returned_needs_note: REWRITE, + done_needs_owner: t.done_needs_owner, + ext_blocked_needs_reason: t.ext_blocked_needs_reason, + }); expect(await table()).toEqual({ - 'zh-CN': { served: both(ZH.done_needs_owner), refusal: both(ZH.done_needs_owner) }, - zh: { served: both(ZH.done_needs_owner), refusal: both(ZH.done_needs_owner) }, - en: { served: both(PACKAGED.done_needs_owner), refusal: both(PACKAGED.done_needs_owner) }, + 'zh-CN': { served: rows(ZH), refusal: rows(ZH) }, + zh: { served: rows(ZH), refusal: rows(ZH) }, + en: { served: rows(PACKAGED), refusal: rows(PACKAGED) }, }); }); }); @@ -197,7 +227,7 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on describe('control: no overlay', () => { beforeEach(async () => { await boot(false); }); - it('translates both packaged rules on both readers', async () => { + it('translates every rule on both readers', async () => { expect(await table()).toEqual({ 'zh-CN': { served: ZH, refusal: ZH }, zh: { served: ZH, refusal: ZH }, diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 6039e16ff35..0ad57b55921 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -413,10 +413,10 @@ export interface ValidationMessageContext { * translation) is not applied to it. * * Absent when no packaged owner is registered for the object (a - * runtime-authored object, a registry-less host): nothing is inferred and - * the catalog applies, as on `/meta` with no base. Present, `undefined` - * means the packaged declaration carries no rule of that name, so the - * served rule was authored after the fact. + * runtime-authored object, a registry-less host), and `undefined` for a rule + * the packaged declaration does not carry (one an `objectExtensions` entry + * folds on, translated under its own name): either way there is nothing to + * diverge from, and the catalog applies, as on `/meta`. */ packagedRule?: (ruleName: string) => unknown; } diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index 54e98ea1897..2292a9a3c48 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -2659,8 +2659,8 @@ describe('evaluateValidationRules — authored rule messages resolve from the bu * rule of the same name, the sentence is the tenant's own and is returned as * written, judged by `scalarOverridesPackagedBase` (the one comparison `/meta` * judges the same message by). `messages.packagedRule` is the engine's lookup - * into the packaged owner's declaration; absent means no packaged base is - * known, and the catalog applies as it always did. + * into the packaged owner's declaration; absent, or answering no rule of the + * name, there is nothing to diverge from and the catalog applies as before. */ describe('evaluateValidationRules — a tenant-rewritten rule message beats the packaged translation (ADR-0029 D9.2a)', () => { const PACKAGED = 'A standing duty must declare a frequency.'; @@ -2705,9 +2705,9 @@ describe('evaluateValidationRules — a tenant-rewritten rule message beats the expect(err.message).toBe(ZH); }); - it('counts a rule the packaged object does not declare as authored', () => { + it('translates a rule the packaged object does not declare (an extension rule), under its own name', () => { const [err] = failing(schemaWith(REWRITE), {}, { messages: messages({ packagedRule: () => undefined }) }); - expect(err.message).toBe(REWRITE); + expect(err.message).toBe(ZH); }); it('infers nothing without a packaged base: the catalog applies, as before', () => { diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 20ee52e5b9a..1c022209dbe 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -3768,8 +3768,8 @@ export function emitValidationAdvisories( * `scalarOverridesPackagedBase`, the one comparison `/meta`'s * `translateObjectValidations` judges the same message by (⛔ never a second * one here), so the served rule and the refusal of the same write keep - * showing one sentence. A rule the packaged declaration does not carry counts - * as diverged; no packaged rule lookup means nothing is inferred. + * showing one sentence. A rule the packaged declaration does not carry, or no + * lookup at all, has nothing to diverge from: the catalog applies. */ function authoredRuleMessage( rule: Pick, @@ -3777,8 +3777,7 @@ function authoredRuleMessage( ): string { if (messages?.translate && messages.objectName && messages.locale && typeof rule.name === 'string' && rule.name.length > 0) { - if (messages.packagedRule - && scalarOverridesPackagedBase(messages.packagedRule(rule.name) ?? {}, 'message', rule.message)) { + if (scalarOverridesPackagedBase(messages.packagedRule?.(rule.name), 'message', rule.message)) { return rule.message; } const key = objectValidationMessageKey(messages.objectName, rule.name); diff --git a/packages/spec/src/system/i18n-resolver.object-validations.test.ts b/packages/spec/src/system/i18n-resolver.object-validations.test.ts index 50c1103a94e..4c0762fbdb1 100644 --- a/packages/spec/src/system/i18n-resolver.object-validations.test.ts +++ b/packages/spec/src/system/i18n-resolver.object-validations.test.ts @@ -271,23 +271,26 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message beats the packaged tran vip_branch: 'Every VIP ticket needs a named owner.', }; - /** The tenant's overlay: two messages rewritten, one rule of its own. */ + /** + * The tenant's overlay: two messages rewritten, and one rule the packaged + * object does not declare (as an `objectExtensions` rule is folded on). + */ const tenantDoc = () => { const doc = clone(DOC); doc.validations[0].message = REWRITE.closed_is_final; doc.validations[3].then.message = REWRITE.vip_branch; doc.validations.push(rule({ type: 'script', - name: 'tenant_only_rule', - message: 'Our own rule, in our own words.', + name: 'unpackaged_rule', + message: 'A rule the package does not declare.', condition: 'record.owner == null', })); return doc; }; - /** A catalog entry for the tenant's own rule too: it still must not apply. */ + /** The catalog carries an entry for the unpackaged rule under its own name. */ const bundle = TranslationBundleSchema.parse({ - 'zh-CN': localeData({ ...ZH, tenant_only_rule: '不该出现的译文。' }), + 'zh-CN': localeData({ ...ZH, unpackaged_rule: '套件未声明的规则。' }), }); it('serves the rewrite as written, and still translates every rule the tenant left alone', () => { @@ -299,16 +302,17 @@ describe('ADR-0029 D9.2a a tenant-rewritten rule message beats the packaged tran wrapper: AUTHORED.wrapper, vip_branch: REWRITE.vip_branch, standard_branch: ZH.standard_branch, - // A rule the packaged object does not declare was authored after the fact. - tenant_only_rule: 'Our own rule, in our own words.', + // Nothing to diverge from: its own entry still applies. + unpackaged_rule: '套件未声明的规则。', }); }); - it('counts every named rule as authored when the packaged object declares none', () => { + it('applies the catalog to every rule when the packaged object declares none', () => { const { validations: _dropped, ...bare } = clone(DOC); - const doc = tenantDoc(); - const out = translateObject(doc, bundle, { locale: 'zh-CN', packagedBase: bare }) as any; - expect(out.validations).toBe(doc.validations); + const out = translateObject(tenantDoc(), bundle, { locale: 'zh-CN', packagedBase: bare }) as any; + expect(out.validations[0].message).toBe(ZH.closed_is_final); + expect(out.validations[3].then.message).toBe(ZH.vip_branch); + expect(out.validations[4].message).toBe('套件未声明的规则。'); }); it('infers nothing without a packaged base: the catalog applies, as before', () => { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index e97c4570453..0d0d431f933 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -4003,9 +4003,15 @@ const MAX_VALIDATION_DEPTH = 10; * rule and reworded it — is authored data and keeps its text, judged by * {@link valueOverridesPackagedBase} (⛔ never a second comparison) against * the rule in {@link TranslateDocumentOptions.packagedBase}'s `validations`, - * a `conditional` branch by its own name. A rule the base does not declare was - * authored after the fact, and its message counts as diverged; no base - * supplied means nothing is inferred and the catalog applies. + * a `conditional` branch by its own name. + * + * A rule the base does not declare has no packaged message to diverge from, + * so nothing is inferred and the catalog applies, as it does with no base at + * all (the predicate's own first edge). Unlike a list view, a rule is + * translated under its OWN name: a rule an `objectExtensions` entry folds onto + * the object, absent from the owner's declaration by construction, is + * translated by its package's entry for that name, and counting it as + * diverged would serve it untranslated. * * The evaluator applies the same rule to its refusal, through the exported * {@link scalarOverridesPackagedBase} and the packaged owner's declaration the @@ -4034,9 +4040,7 @@ function translateObjectValidations( }; } if (typeof rule.name !== 'string' || rule.name.length === 0) return raw; - if (packaged !== undefined && valueOverridesPackagedBase(packaged.get(rule.name) ?? {}, 'message', rule.message)) { - return raw; - } + if (valueOverridesPackagedBase(packaged.get(rule.name), 'message', rule.message)) return raw; const path = objectValidationMessageKey(objectName, rule.name).split('.'); for (const code of localeChain(opts)) { const candidate = path.reduce( @@ -4061,14 +4065,11 @@ function translateObjectValidations( /** * The packaged object's validation rules by `name`, each `conditional` branch * under its own name to {@link MAX_VALIDATION_DEPTH}, for - * {@link translateObjectValidations}' divergence test. `undefined` when no - * base is known; an empty map when the base declares no rules, so every rule - * on the served document then counts as authored. The first rule of a name - * wins. + * {@link translateObjectValidations}' divergence test — empty when no base is + * known or it declares no rules. The first rule of a name wins. */ -function packagedRulesByName(packagedBase: unknown): Map> | undefined { +function packagedRulesByName(packagedBase: unknown): Map> { const base = asRecord(packagedBase); - if (base === undefined) return undefined; const byName = new Map>(); const visit = (raw: unknown, depth: number): void => { const rule = asRecord(raw); @@ -4080,7 +4081,8 @@ function packagedRulesByName(packagedBase: unknown): Map 0 && !byName.has(rule.name)) byName.set(rule.name, rule); }; - if (Array.isArray(base.validations)) for (const raw of base.validations) visit(raw, 0); + const rules = base?.validations; + if (Array.isArray(rules)) for (const raw of rules) visit(raw, 0); return byName; } From e7578942cbc4905ec3e217fed58f1bbea6e87ba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:33:03 +0000 Subject: [PATCH 5/7] chore(changeset): say that an unpackaged rule keeps its translation Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc Co-authored-by: Claude --- .changeset/27221-tenant-rewritten-rule-message-wins.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/27221-tenant-rewritten-rule-message-wins.md b/.changeset/27221-tenant-rewritten-rule-message-wins.md index f2a1bddbc67..ce8484907cc 100644 --- a/.changeset/27221-tenant-rewritten-rule-message-wins.md +++ b/.changeset/27221-tenant-rewritten-rule-message-wins.md @@ -10,7 +10,7 @@ Metadata-protocol: semantic A package's bundle translates the rule messages that package shipped (`objects.._validations..message`). When a tenant overlaid a packaged object and reworded one of its validation rule messages, keeping the rule's name, a reader in any locale the bundle carries got the package's translation instead of the tenant's sentence. The rejected write's error and the rule served by `/meta` (and the public form) both showed it. -- **Now.** Both readers apply the bundle entry only while the served message still equals the packaged rule's message of the same name. A message that differs is the tenant's own and is served and returned as written, in every locale. Rules the tenant left alone are still translated. A `conditional` branch is matched by its own name. A rule the package never shipped counts as the tenant's own, so a bundle entry for it no longer applies once the object has a packaged owner. With no packaged owner (an object authored entirely at runtime), nothing changes. +- **Now.** Both readers apply the bundle entry only while the served message still equals the packaged rule's message of the same name. A message that differs is the tenant's own and is served and returned as written, in every locale. Rules the tenant left alone are still translated. A `conditional` branch is matched by its own name. A rule the owning package does not declare (one an `objectExtensions` entry adds, or one the tenant added) has no packaged message to differ from and is still translated under its own name, as before. With no packaged owner (an object authored entirely at runtime), nothing changes. - **One rule, both readers.** `/meta` (`translateObject`) and the rule evaluator use the same comparison, the one already applied to an object's `label`, `description`, embedded list views and fields. `/meta` and the refusal of the same write still show one sentence for one rule and locale. - **`scalarOverridesPackagedBase`** (`@objectstack/spec/system`) now also accepts the key `'message'`, so the rule evaluator judges a rule message through it. Its behaviour for the existing keys is unchanged. - **`evaluateValidationRules`'s `messages` option** (`EvaluateRulesOptions.messages`, `@objectstack/objectql`) gains an optional `packagedRule` lookup, which the engine fills from the object's packaged owner. A caller that passes its own `messages` without it keeps the previous behaviour. From e4a45ed89bb89bd0ce375b19127fb11e9dca5093 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:13:54 +0000 Subject: [PATCH 6/7] docs(i18n): validation rule messages follow the edit-beats-catalog rule Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc Co-authored-by: Claude --- content/docs/ui/translations.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index b05d645407e..8001e241935 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -223,6 +223,19 @@ Objects follow the same rule for their `label`, `pluralLabel` and label an [object extension](/docs/data-modeling/object-extensions) contributes, for one) is served as written. +An object's validation rule messages follow it too, on both places a reader +sees them: the error a rejected write returns, and the rule served with the +object (`/meta`, and the forms built from it). The rule's `message` is compared +with the message of the rule of the same `name` in the owning package's +declaration, a `conditional` rule's `then` / `otherwise` branch by its own name. +An organization that overlays a packaged object and rewords a rule's message, +keeping its name, gets that sentence as written in every locale; the rules it +left alone are still translated, and both places always show the same sentence. +Unlike a widget or a bulk action, a rule the owning package does not declare +(one an object extension adds, or one the organization added) is **still +translated** under its own name, `objects.._validations..message`: +there is no shipped message for it to differ from. + ## Organizing the files How you lay out translation source files is an authoring convention — your From 3e8364560d176c6cae02faa7db982154b88fd54d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:29:13 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix(objectql):=20validationMessageContext?= =?UTF-8?q?=20declares=20the=20packagedRule=20its=20body=20returns;=20chan?= =?UTF-8?q?geset=20declares=20Clause-=E2=91=A1=20yes=20(widening)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract review of record found the declaration short: the exported scalarOverridesPackagedBase key union and EvaluateRulesOptions.messages' packagedRule both widen a published surface. Claude-Session: https://claude.ai/code/session_01L6bhgW82YGUW8s8X4Dgiqc Co-authored-by: Claude --- .changeset/27221-tenant-rewritten-rule-message-wins.md | 2 +- packages/objectql/src/engine.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/27221-tenant-rewritten-rule-message-wins.md b/.changeset/27221-tenant-rewritten-rule-message-wins.md index ce8484907cc..d417320b711 100644 --- a/.changeset/27221-tenant-rewritten-rule-message-wins.md +++ b/.changeset/27221-tenant-rewritten-rule-message-wins.md @@ -5,7 +5,7 @@ fix(i18n): a tenant's rewrite of a packaged validation rule message is served as written in every locale, on the refusal and on `/meta` (#27221) -Clause-②: no +Clause-②: yes (widening) Metadata-protocol: semantic A package's bundle translates the rule messages that package shipped (`objects.._validations..message`). When a tenant overlaid a packaged object and reworded one of its validation rule messages, keeping the rule's name, a reader in any locale the bundle carries got the package's translation instead of the tenant's sentence. The rejected write's error and the rule served by `/meta` (and the public form) both showed it. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index b63ecbb6df5..ff193bf862f 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -335,7 +335,7 @@ import { viewContainerNameRefusal } from './view-container-name-refusal.js'; import { bindHooksToEngine, type BindHooksOptions } from './hook-binder.js'; import { declaredHookCondition } from './hook-wrappers.js'; import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, type FieldValidationError, isScannableValueShapeField } from './validation/record-validator.js'; -import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; +import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink, ValidationMessageContext } from './validation/record-validator.js'; import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js'; import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js'; import { collectReferenceTraversals, referenceTraversalConflicts, evaluateValidationRules, emitValidationAdvisories, optionVisibilityReadsPermissions, readsPermissionPredicate, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; @@ -9542,7 +9542,7 @@ export class ObjectQL implements IObjectQLEngine { // Only `locale` is read — narrower than `ExecutionContext` so both the // resolved and the input-shaped envelope satisfy it. context?: { locale?: string }, - ): { locale?: string; translate?: (key: string, locale: string, params?: Record) => string; objectName: string } { + ): ValidationMessageContext & { objectName: string } { // [#25522 cut 1] The body moved to `engine-validation-messages.ts` (see `ValidationMessageHost`). const readI18nService = () => this.i18nService; const readRegistry = () => this._registry;