Skip to content
Merged
16 changes: 16 additions & 0 deletions .changeset/27221-tenant-rewritten-rule-message-wins.md
Original file line number Diff line number Diff line change
@@ -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-②: yes (widening)
Metadata-protocol: semantic

A package's bundle translates the rule messages that package shipped (`objects.<object>._validations.<rule>.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 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.
13 changes: 13 additions & 0 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>._validations.<rule>.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
Expand Down
74 changes: 71 additions & 3 deletions packages/objectql/src/engine-validation-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@
* 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
* move.
*/

import type { ObjectQL } from './engine.js';
import type { ValidationMessageContext } from './validation/record-validator.js';
import { objectLabelKey, resolveBundleLocale } from '@objectstack/spec/system';

/**
Expand All @@ -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<ValidationMessageHost, 'i18nService' | 'negotiatedMessageLocale'>,
this: Pick<ValidationMessageHost, 'i18nService' | 'registry' | 'negotiatedMessageLocale'>,
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, unknown>) => 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<string, unknown> | undefined;
return (ruleName) => {
if (byName === undefined) {
const index = new Map<string, unknown>();
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);
};
}

Expand Down
238 changes: 238 additions & 0 deletions packages/objectql/src/engine-validation-tenant-rewrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
// 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.<o>._validations.<rule>.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;
* - 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 —
* `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 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.';

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<string, any> = {
en: {
objects: {
[OBJ]: {
_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 },
},
},
},
},
'zh-CN': {
objects: {
[OBJ]: {
_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 },
},
},
},
},
};

/** `FileI18nAdapter.t()`-shaped: the locale exactly, then the fallback, then the key echoed. */
function servedI18n(bundle: Record<string, any>, fallbackLocale = 'en') {
const dig = (data: unknown, key: string): unknown =>
key.split('.').reduce<unknown>(
(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<string, any>();
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);
engine.registry.registerObject(EXTENSION as any, EXT_PKG, undefined, 'extend');
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<string, unknown>): Promise<string> {
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<string, string> {
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<string, unknown> = {};
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' }),
ext_blocked_needs_reason: await refusal(header, { id: 'r3', status: 'blocked' }),
},
};
}
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', () => {
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);
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 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: rows(ZH), refusal: rows(ZH) },
zh: { served: rows(ZH), refusal: rows(ZH) },
en: { served: rows(PACKAGED), refusal: rows(PACKAGED) },
});
});
});

describe('control: no overlay', () => {
beforeEach(async () => { await boot(false); });

it('translates every rule on both readers', async () => {
expect(await table()).toEqual({
'zh-CN': { served: ZH, refusal: ZH },
zh: { served: ZH, refusal: ZH },
en: { served: PACKAGED, refusal: PACKAGED },
});
});
});
});
Loading
Loading